jpmorard commited on
Commit
a8eded0
·
verified ·
1 Parent(s): c9b97c6

chore: deploy AGILAB release Space (2026-10-11T07:35:14Z) (part 4)

Browse files
Files changed (29) hide show
  1. src/agilab/lib/agi-app-data-quality-gate/pyproject.toml +1 -1
  2. src/agilab/lib/agi-app-flight-telemetry/pyproject.toml +1 -1
  3. src/agilab/lib/agi-app-learning-assessment/pyproject.toml +1 -1
  4. src/agilab/lib/agi-app-minimal/pyproject.toml +2 -2
  5. src/agilab/lib/agi-app-mission-decision/pyproject.toml +1 -1
  6. src/agilab/lib/agi-app-multi-dag/pyproject.toml +1 -1
  7. src/agilab/lib/agi-app-pandas-execution/pyproject.toml +1 -1
  8. src/agilab/lib/agi-app-polars-execution/pyproject.toml +1 -1
  9. src/agilab/lib/agi-app-pytorch-playground/pyproject.toml +1 -1
  10. src/agilab/lib/agi-app-r-runtime-bridge/pyproject.toml +2 -2
  11. src/agilab/lib/agi-app-sklearn-pipeline/pyproject.toml +1 -1
  12. src/agilab/lib/agi-app-uav-queue/pyproject.toml +1 -1
  13. src/agilab/lib/agi-app-uav-relay-queue/pyproject.toml +1 -1
  14. src/agilab/lib/agi-app-weather-forecast/pyproject.toml +1 -1
  15. src/agilab/lib/agi-apps/pyproject.toml +2 -2
  16. src/agilab/lib/agi-gui/pyproject.toml +2 -2
  17. src/agilab/lib/agi-gui/src/agi_gui/widgets/file_picker.py +4 -1
  18. src/agilab/lib/agi-gui/test/test_file_picker.py +5 -5
  19. src/agilab/lib/agi-pages/pyproject.toml +2 -2
  20. src/agilab/lib/agi-web/frontend/agilab_react_native_controls_and_progress.test.mjs +160 -0
  21. src/agilab/lib/agi-web/frontend/agilab_react_python_host.css +2 -0
  22. src/agilab/lib/agi-web/frontend/agilab_react_python_host.jsx +40 -0
  23. src/agilab/lib/agi-web/pyproject.toml +1 -1
  24. src/agilab/lib/agi-web/src/agi_web/python_ui.py +9 -1
  25. src/agilab/lib/agi-web/src/agi_web/react_python_host_assets/agilab_react_python_host.css +0 -0
  26. src/agilab/lib/agi-web/src/agi_web/react_python_host_assets/agilab_react_python_host.js +0 -0
  27. src/agilab/lib/agi-web/test/test_python_ui_rendering_and_input_contracts.py +126 -0
  28. src/agilab/notebooks/notebook_export_support.py +6 -1
  29. src/agilab/notebooks/notebook_helper_cell.py +18 -3
src/agilab/lib/agi-app-data-quality-gate/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.04"
3
  name = "agi-app-data-quality-gate"
4
  description = "AGILAB deterministic data contract, drift, leakage, and promotion gate"
5
  requires-python = ">=3.12"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-app-data-quality-gate"
4
  description = "AGILAB deterministic data contract, drift, leakage, and promotion gate"
5
  requires-python = ">=3.12"
src/agilab/lib/agi-app-flight-telemetry/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.04"
3
  name = "agi-app-flight-telemetry"
4
  description = "AGILAB flight-telemetry ingestion demo with reusable dataframe and map-analysis outputs"
5
  requires-python = ">=3.12"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-app-flight-telemetry"
4
  description = "AGILAB flight-telemetry ingestion demo with reusable dataframe and map-analysis outputs"
5
  requires-python = ">=3.12"
src/agilab/lib/agi-app-learning-assessment/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.07"
3
  name = "agi-app-learning-assessment"
4
  description = "AGILAB learning and assessment workflow with evidence scoring and regression-plan artifacts"
5
  requires-python = ">=3.13"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-app-learning-assessment"
4
  description = "AGILAB learning and assessment workflow with evidence scoring and regression-plan artifacts"
5
  requires-python = ">=3.13"
src/agilab/lib/agi-app-minimal/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.04"
3
  name = "agi-app-minimal"
4
  description = "Built-in AGILAB minimal Polars manager and worker example"
5
  requires-python = ">=3.12"
@@ -29,7 +29,7 @@ keywords = [
29
  "workflow-orchestration",
30
  ]
31
 
32
- dependencies = ["agi-env>=2026.10.04,<2027.0", "agi-node>=2026.10.04,<2027.0", "agi-web>=2026.10.04,<2027.0"]
33
 
34
  [project.urls]
35
  Documentation = "https://thalesgroup.github.io/agilab"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-app-minimal"
4
  description = "Built-in AGILAB minimal Polars manager and worker example"
5
  requires-python = ">=3.12"
 
29
  "workflow-orchestration",
30
  ]
31
 
32
+ dependencies = ["agi-env>=2026.10.04,<2027.0", "agi-node>=2026.10.04,<2027.0", "agi-web>=2026.10.10.1,<2027.0"]
33
 
34
  [project.urls]
35
  Documentation = "https://thalesgroup.github.io/agilab"
src/agilab/lib/agi-app-mission-decision/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.04"
3
  name = "agi-app-mission-decision"
4
  description = "Deterministic AGILAB mission-decision workflow with scenario scoring and evidence artifacts"
5
  requires-python = ">=3.12"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-app-mission-decision"
4
  description = "Deterministic AGILAB mission-decision workflow with scenario scoring and evidence artifacts"
5
  requires-python = ">=3.12"
src/agilab/lib/agi-app-multi-dag/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.04"
3
  name = "agi-app-multi-dag"
4
  description = "AGILAB cross-app DAG preview showing artifact handoffs between demo projects"
5
  requires-python = ">=3.12"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-app-multi-dag"
4
  description = "AGILAB cross-app DAG preview showing artifact handoffs between demo projects"
5
  requires-python = ">=3.12"
src/agilab/lib/agi-app-pandas-execution/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.04"
3
  name = "agi-app-pandas-execution"
4
  description = "AGILAB Pandas execution benchmark for deterministic worker and reducer validation"
5
  requires-python = ">=3.12"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-app-pandas-execution"
4
  description = "AGILAB Pandas execution benchmark for deterministic worker and reducer validation"
5
  requires-python = ">=3.12"
src/agilab/lib/agi-app-polars-execution/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.04"
3
  name = "agi-app-polars-execution"
4
  description = "AGILAB Polars execution benchmark for deterministic worker and reducer validation"
5
  requires-python = ">=3.12"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-app-polars-execution"
4
  description = "AGILAB Polars execution benchmark for deterministic worker and reducer validation"
5
  requires-python = ">=3.12"
src/agilab/lib/agi-app-pytorch-playground/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.04"
3
  name = "agi-app-pytorch-playground"
4
  description = "AGILAB PyTorch playground app for reproducible neural-network experiments"
5
  requires-python = ">=3.12"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-app-pytorch-playground"
4
  description = "AGILAB PyTorch playground app for reproducible neural-network experiments"
5
  requires-python = ">=3.12"
src/agilab/lib/agi-app-r-runtime-bridge/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.04"
3
  name = "agi-app-r-runtime-bridge"
4
  description = "Built-in AGILAB Rscript JSON and artifact stage bridge"
5
  requires-python = ">=3.12"
@@ -29,7 +29,7 @@ keywords = [
29
  "workflow-orchestration",
30
  ]
31
 
32
- dependencies = ["agi-env>=2026.10.04,<2027.0", "agi-node>=2026.10.04,<2027.0", "agi-web>=2026.10.04,<2027.0"]
33
 
34
  [project.urls]
35
  Documentation = "https://thalesgroup.github.io/agilab"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-app-r-runtime-bridge"
4
  description = "Built-in AGILAB Rscript JSON and artifact stage bridge"
5
  requires-python = ">=3.12"
 
29
  "workflow-orchestration",
30
  ]
31
 
32
+ dependencies = ["agi-env>=2026.10.04,<2027.0", "agi-node>=2026.10.04,<2027.0", "agi-web>=2026.10.10.1,<2027.0"]
33
 
34
  [project.urls]
35
  Documentation = "https://thalesgroup.github.io/agilab"
src/agilab/lib/agi-app-sklearn-pipeline/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.04"
3
  name = "agi-app-sklearn-pipeline"
4
  description = "AGILAB scikit-learn pipeline app with model, metrics, predictions, and evidence manifest"
5
  requires-python = ">=3.12"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-app-sklearn-pipeline"
4
  description = "AGILAB scikit-learn pipeline app with model, metrics, predictions, and evidence manifest"
5
  requires-python = ">=3.12"
src/agilab/lib/agi-app-uav-queue/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.04"
3
  name = "agi-app-uav-queue"
4
  description = "AGILAB UAV queue simulation demo with scenario evidence and network-map artifacts"
5
  requires-python = ">=3.13"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-app-uav-queue"
4
  description = "AGILAB UAV queue simulation demo with scenario evidence and network-map artifacts"
5
  requires-python = ">=3.13"
src/agilab/lib/agi-app-uav-relay-queue/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.04"
3
  name = "agi-app-uav-relay-queue"
4
  description = "AGILAB UAV relay queue simulation demo with routing, delay, and resilience artifacts"
5
  requires-python = ">=3.13"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-app-uav-relay-queue"
4
  description = "AGILAB UAV relay queue simulation demo with routing, delay, and resilience artifacts"
5
  requires-python = ">=3.13"
src/agilab/lib/agi-app-weather-forecast/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.04"
3
  name = "agi-app-weather-forecast"
4
  description = "AGILAB weather-forecast notebook-migration demo with forecast analysis artifacts"
5
  requires-python = ">=3.12"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-app-weather-forecast"
4
  description = "AGILAB weather-forecast notebook-migration demo with forecast analysis artifacts"
5
  requires-python = ">=3.12"
src/agilab/lib/agi-apps/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.07"
3
  name = "agi-apps"
4
  description = "AGILAB public app package umbrella and example assets"
5
  requires-python = ">=3.12"
@@ -31,7 +31,7 @@ keywords = [
31
  "workflow-orchestration",
32
  ]
33
 
34
- dependencies = ["agi-core==2026.10.05", "agi-app-mission-decision==2026.10.04", "agi-app-pandas-execution==2026.10.04", "agi-app-polars-execution==2026.10.04", "agi-app-flight-telemetry==2026.10.04", "agi-app-multi-dag==2026.10.04", "agi-app-weather-forecast==2026.10.04", "agi-app-sklearn-pipeline==2026.10.04", "agi-app-data-quality-gate==2026.10.04", "agi-app-pytorch-playground==2026.10.04; python_version >= '3.12'", "agi-app-learning-assessment==2026.10.07; python_version >= '3.13'", "agi-app-uav-relay-queue==2026.10.04; python_version >= '3.13'"]
35
 
36
  [project.urls]
37
  Documentation = "https://thalesgroup.github.io/agilab"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-apps"
4
  description = "AGILAB public app package umbrella and example assets"
5
  requires-python = ">=3.12"
 
31
  "workflow-orchestration",
32
  ]
33
 
34
+ dependencies = ["agi-core==2026.10.10.1", "agi-app-mission-decision==2026.10.10.1", "agi-app-pandas-execution==2026.10.10.1", "agi-app-polars-execution==2026.10.10.1", "agi-app-flight-telemetry==2026.10.10.1", "agi-app-multi-dag==2026.10.10.1", "agi-app-weather-forecast==2026.10.10.1", "agi-app-sklearn-pipeline==2026.10.10.1", "agi-app-data-quality-gate==2026.10.10.1", "agi-app-pytorch-playground==2026.10.10.1; python_version >= '3.12'", "agi-app-learning-assessment==2026.10.10.1; python_version >= '3.13'", "agi-app-uav-relay-queue==2026.10.10.1; python_version >= '3.13'"]
35
 
36
  [project.urls]
37
  Documentation = "https://thalesgroup.github.io/agilab"
src/agilab/lib/agi-gui/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.08"
3
  name = "agi-gui"
4
  description = "AGILAB native React UI dependency bundle and Python view helpers"
5
  requires-python = ">=3.12"
@@ -31,7 +31,7 @@ keywords = [
31
  "python",
32
  ]
33
 
34
- dependencies = ["agi-env==2026.10.04", "agi-web==2026.10.08", "starlette>=1.0.1,<2", "watchdog>=6,<7"]
35
 
36
  [project.urls]
37
  Documentation = "https://thalesgroup.github.io/agilab"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-gui"
4
  description = "AGILAB native React UI dependency bundle and Python view helpers"
5
  requires-python = ">=3.12"
 
31
  "python",
32
  ]
33
 
34
+ dependencies = ["agi-env==2026.10.04", "agi-web==2026.10.10.1", "starlette>=1.0.1,<2", "watchdog>=6,<7"]
35
 
36
  [project.urls]
37
  Documentation = "https://thalesgroup.github.io/agilab"
src/agilab/lib/agi-gui/src/agi_gui/widgets/file_picker.py CHANGED
@@ -229,7 +229,7 @@ def agi_file_picker(
229
  help: str | None = None,
230
  container: Any | None = None,
231
  ) -> str | list[str] | None:
232
- """Render a Streamlit popover file picker and return selected absolute paths."""
233
 
234
  if selection_mode not in {"single", "multi"}:
235
  raise ValueError("selection_mode must be 'single' or 'multi'")
@@ -339,6 +339,7 @@ def agi_file_picker(
339
  st.error(str(exc))
340
  else:
341
  st.session_state[state_key] = [str(selected_manual)]
 
342
  st.rerun()
343
 
344
  if allow_upload:
@@ -352,6 +353,8 @@ def agi_file_picker(
352
  saved_paths = _save_uploaded_files(uploaded_files, upload_dir, root_items)
353
  if saved_paths:
354
  st.session_state[state_key] = saved_paths[:1] if selection_mode == "single" else saved_paths
 
 
355
  st.rerun()
356
 
357
  final_selection = _validated_selection(st.session_state.get(state_key), root_items, selection_mode)
 
229
  help: str | None = None,
230
  container: Any | None = None,
231
  ) -> str | list[str] | None:
232
+ """Render a native popover file picker and return selected absolute paths."""
233
 
234
  if selection_mode not in {"single", "multi"}:
235
  raise ValueError("selection_mode must be 'single' or 'multi'")
 
339
  st.error(str(exc))
340
  else:
341
  st.session_state[state_key] = [str(selected_manual)]
342
+ st.session_state.pop(table_key, None)
343
  st.rerun()
344
 
345
  if allow_upload:
 
353
  saved_paths = _save_uploaded_files(uploaded_files, upload_dir, root_items)
354
  if saved_paths:
355
  st.session_state[state_key] = saved_paths[:1] if selection_mode == "single" else saved_paths
356
+ st.session_state.pop(table_key, None)
357
+ st.session_state[upload_key] = []
358
  st.rerun()
359
 
360
  final_selection = _validated_selection(st.session_state.get(state_key), root_items, selection_mode)
src/agilab/lib/agi-gui/test/test_file_picker.py CHANGED
@@ -1,6 +1,6 @@
1
  from __future__ import annotations
2
 
3
- import agi_web.python_ui
4
  from pathlib import Path
5
  from types import SimpleNamespace
6
 
@@ -263,7 +263,7 @@ def test_agi_file_picker_selects_dataframe_row(tmp_path: Path, monkeypatch) -> N
263
  selected_file.write_text("a\n", encoding="utf-8")
264
  (tmp_path / "beta.txt").write_text("b\n", encoding="utf-8")
265
  fake_st = _FakeStreamlit(dataframe_state={"selection": {"rows": [0]}})
266
- monkeypatch.setattr(agi_web, "python_ui", fake_st)
267
 
268
  result = agi_file_picker(
269
  "Browse",
@@ -294,7 +294,7 @@ def test_agi_file_picker_handles_multi_root_empty_search_and_manual_error(tmp_pa
294
  button_values={"picker:use_manual_path": True},
295
  pills_value="unknown",
296
  )
297
- monkeypatch.setattr(agi_web, "python_ui", fake_st)
298
 
299
  result = agi_file_picker(
300
  "Browse",
@@ -315,7 +315,7 @@ def test_agi_file_picker_manual_path_updates_selection(tmp_path: Path, monkeypat
315
  text_values={"picker:manual_path": str(selected_file)},
316
  button_values={"picker:use_manual_path": True},
317
  )
318
- monkeypatch.setattr(agi_web, "python_ui", fake_st)
319
 
320
  result = agi_file_picker("Browse", roots={"Project": tmp_path}, key="picker")
321
 
@@ -330,7 +330,7 @@ def test_agi_file_picker_upload_saves_files(tmp_path: Path, monkeypatch) -> None
330
  fake_st = _FakeStreamlit(
331
  uploads=[_UploadedFile("nested/new.csv", b"payload")],
332
  )
333
- monkeypatch.setattr(agi_web, "python_ui", fake_st)
334
 
335
  result = agi_file_picker(
336
  "Upload",
 
1
  from __future__ import annotations
2
 
3
+ from importlib import import_module
4
  from pathlib import Path
5
  from types import SimpleNamespace
6
 
 
263
  selected_file.write_text("a\n", encoding="utf-8")
264
  (tmp_path / "beta.txt").write_text("b\n", encoding="utf-8")
265
  fake_st = _FakeStreamlit(dataframe_state={"selection": {"rows": [0]}})
266
+ monkeypatch.setattr(import_module("agi_web"), "python_ui", fake_st, raising=False)
267
 
268
  result = agi_file_picker(
269
  "Browse",
 
294
  button_values={"picker:use_manual_path": True},
295
  pills_value="unknown",
296
  )
297
+ monkeypatch.setattr(import_module("agi_web"), "python_ui", fake_st, raising=False)
298
 
299
  result = agi_file_picker(
300
  "Browse",
 
315
  text_values={"picker:manual_path": str(selected_file)},
316
  button_values={"picker:use_manual_path": True},
317
  )
318
+ monkeypatch.setattr(import_module("agi_web"), "python_ui", fake_st, raising=False)
319
 
320
  result = agi_file_picker("Browse", roots={"Project": tmp_path}, key="picker")
321
 
 
330
  fake_st = _FakeStreamlit(
331
  uploads=[_UploadedFile("nested/new.csv", b"payload")],
332
  )
333
+ monkeypatch.setattr(import_module("agi_web"), "python_ui", fake_st, raising=False)
334
 
335
  result = agi_file_picker(
336
  "Upload",
src/agilab/lib/agi-pages/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.08"
3
  name = "agi-pages"
4
  description = "AGILAB public analysis page umbrella and provider package"
5
  requires-python = ">=3.12"
@@ -34,7 +34,7 @@ keywords = [
34
  "page-bundles",
35
  ]
36
 
37
- dependencies = ["agi-gui==2026.10.08"]
38
 
39
  [project.entry-points."agi_env.paths"]
40
  pages = "agi_pages:bundles_root"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-pages"
4
  description = "AGILAB public analysis page umbrella and provider package"
5
  requires-python = ">=3.12"
 
34
  "page-bundles",
35
  ]
36
 
37
+ dependencies = ["agi-gui==2026.10.10.1"]
38
 
39
  [project.entry-points."agi_env.paths"]
40
  pages = "agi_pages:bundles_root"
src/agilab/lib/agi-web/frontend/agilab_react_native_controls_and_progress.test.mjs CHANGED
@@ -329,3 +329,163 @@ test("image descriptions and stretch widths reach the rendered plot", async () =
329
  assert.equal(images[2].getAttribute("alt"), "", "Only an explicit decorative alt may be empty.");
330
  assert.equal(images[3].hasAttribute("alt"), false, "An empty caption must not mark an undescribed plot decorative.");
331
  });
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
329
  assert.equal(images[2].getAttribute("alt"), "", "Only an explicit decorative alt may be empty.");
330
  assert.equal(images[3].hasAttribute("alt"), false, "An empty caption must not mark an undescribed plot decorative.");
331
  });
332
+
333
+
334
+ test("file popovers retain controls during rerenders and close with Escape or outside interaction", async () => {
335
+ const disclosure = revision => {
336
+ const item = node("dataset-picker", "popover", {
337
+ label: revision === 1 ? "Choose dataset" : "Dataset selected",
338
+ help: "Choose a CSV under the project", width: "stretch",
339
+ });
340
+ item.children = [
341
+ node("dataset-filter", "text_input", { label: "Filter files", value: "input", key: "filter" }),
342
+ node("dataset-select", "button", { label: "Select dataset", key: "choose" }),
343
+ ];
344
+ return item;
345
+ };
346
+ const actions = [];
347
+ const element = await mount(payload(1, [disclosure(1)]), {
348
+ action: async action => { actions.push(action); return payload(2, [disclosure(2)]); },
349
+ });
350
+ const trigger = element.querySelector(".py-popover-trigger");
351
+ const panel = element.querySelector(".py-popover-panel");
352
+ const input = element.querySelector("#dataset-filter");
353
+ assert.equal(trigger.tagName, "BUTTON", "Use native keyboard activation.");
354
+ assert.equal(trigger.getAttribute("aria-expanded"), "false");
355
+ assert.equal(trigger.getAttribute("aria-controls"), panel.id);
356
+ assert.equal(panel.getAttribute("role"), "dialog");
357
+ assert.equal(panel.hidden, true);
358
+ assert.equal(element.querySelector(".py-popover").style.width, "100%");
359
+ await act(() => trigger.click());
360
+ assert.equal(panel.hidden, false);
361
+ assert.equal(trigger.getAttribute("aria-expanded"), "true");
362
+ assert.equal(actions.length, 0, "Opening a disclosure needs no server roundtrip.");
363
+ input.focus();
364
+ await act(() => input.dispatchEvent(new Event("pointerdown", { bubbles: true })));
365
+ assert.equal(panel.hidden, false);
366
+ await act(() => input.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true })));
367
+ assert.equal(panel.hidden, true);
368
+ assert.equal(document.activeElement, trigger);
369
+ await act(() => trigger.click());
370
+ await act(async () => element.querySelector("#dataset-select").click());
371
+ assert.equal(actions.length, 1);
372
+ assert.equal(actions[0].id, "dataset-select");
373
+ assert.equal(element.querySelector(".py-popover-trigger"), trigger);
374
+ assert.equal(trigger.textContent, "Dataset selected");
375
+ assert.equal(panel.hidden, false);
376
+ assert.equal(element.querySelector("#dataset-filter"), input);
377
+ assert.equal(input.value, "input");
378
+ const outside = document.createElement("button");
379
+ document.body.append(outside);
380
+ await act(() => outside.focus());
381
+ assert.equal(panel.hidden, true, "Leaving the popover by keyboard closes it.");
382
+ await act(() => trigger.click());
383
+ assert.equal(panel.hidden, false);
384
+ await act(() => outside.dispatchEvent(new Event("pointerdown", { bubbles: true })));
385
+ assert.equal(panel.hidden, true);
386
+ assert.equal(document.activeElement, outside, "Closing outside must not steal focus.");
387
+ });
388
+
389
+ test("disabled popovers keep their child controls hidden", async () => {
390
+ const item = node("disabled-picker", "popover", { label: "Unavailable", disabled: true });
391
+ item.children = [node("disabled-child", "text_input", { label: "Hidden child", value: "" })];
392
+ const element = await mount(payload(1, [item]), { action: async () => assert.fail("Unexpected server action") });
393
+ const trigger = element.querySelector(".py-popover-trigger");
394
+ assert.equal(trigger.disabled, true);
395
+ await act(() => trigger.click());
396
+ assert.equal(trigger.getAttribute("aria-expanded"), "false");
397
+ assert.equal(element.querySelector(".py-popover-panel").hidden, true);
398
+ });
399
+
400
+
401
+ test("the packaged production bundle renders an interactive file popover", async () => {
402
+ const packaged = new URL("../src/agi_web/react_python_host_assets/agilab_react_python_host.js", import.meta.url);
403
+ const module = path.join(scratch, "agilab_native_packaged_popover_host_exact_bytes.mjs");
404
+ await copyFile(packaged, module);
405
+ assert.deepEqual(await readFile(module), await readFile(packaged));
406
+ const { mountPythonView: mountPackaged } = await import(pathToFileURL(module).href);
407
+ const item = node("packaged-picker", "popover", { label: "Choose CSV", width: "stretch" });
408
+ item.children = [node("packaged-filter", "text_input", { label: "Filter files", value: "" })];
409
+ const element = document.createElement("div"); document.body.append(element);
410
+ cleanup = mountPackaged(element, { initialPayload: payload(1, [item]), transport: {
411
+ action: async () => assert.fail("Popover interactions must stay local"),
412
+ } });
413
+ const waitFor = async predicate => {
414
+ for (let attempt = 0; attempt < 100; attempt++) {
415
+ if (predicate()) return;
416
+ await new Promise(resolve => setTimeout(resolve, 1));
417
+ }
418
+ assert.fail("The packaged popover did not reach its expected state.");
419
+ };
420
+ await waitFor(() => element.querySelector(".py-popover-trigger"));
421
+ const trigger = element.querySelector(".py-popover-trigger");
422
+ const panel = element.querySelector(".py-popover-panel");
423
+ assert.equal(panel.hidden, true);
424
+ trigger.click();
425
+ await waitFor(() => !panel.hidden);
426
+ const input = element.querySelector("#packaged-filter");
427
+ input.focus();
428
+ input.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
429
+ await waitFor(() => panel.hidden);
430
+ assert.equal(document.activeElement, trigger);
431
+ });
432
+
433
+
434
+ test("packaged nested popovers retain state on rerender and Escape closes only the deepest panel", async () => {
435
+ const packaged = new URL("../src/agi_web/react_python_host_assets/agilab_react_python_host.js", import.meta.url);
436
+ const module = path.join(scratch, "agilab_native_packaged_nested_popover_host_exact_bytes.mjs");
437
+ await copyFile(packaged, module);
438
+ assert.deepEqual(await readFile(module), await readFile(packaged));
439
+ const { mountPythonView: mountPackaged } = await import(pathToFileURL(module).href);
440
+ const nested = revision => {
441
+ const parent = node("outer-picker", "popover", { label: revision === 1 ? "Choose dataset" : "Dataset selected" });
442
+ const child = node("inner-picker", "popover", { label: "Advanced" });
443
+ child.children = [
444
+ node("inner-filter", "text_input", { label: "Filter files", value: "input" }),
445
+ node("inner-apply", "button", { label: "Apply filter" }),
446
+ ];
447
+ parent.children = [child];
448
+ return payload(revision, [parent]);
449
+ };
450
+ const element = document.createElement("div"); document.body.append(element);
451
+ const actions = [];
452
+ cleanup = mountPackaged(element, { initialPayload: nested(1), transport: {
453
+ action: async action => { actions.push(action); return nested(2); },
454
+ } });
455
+ const waitFor = async predicate => {
456
+ for (let attempt = 0; attempt < 100; attempt++) {
457
+ if (predicate()) return;
458
+ await new Promise(resolve => setTimeout(resolve, 1));
459
+ }
460
+ assert.fail("The packaged nested popovers did not reach their expected state.");
461
+ };
462
+ await waitFor(() => element.querySelector("#outer-picker-popover"));
463
+ const parentPanel = element.querySelector("#outer-picker-popover");
464
+ const childPanel = element.querySelector("#inner-picker-popover");
465
+ const parentTrigger = element.querySelector('[aria-controls="outer-picker-popover"]');
466
+ const childTrigger = element.querySelector('[aria-controls="inner-picker-popover"]');
467
+ parentTrigger.focus();
468
+ parentTrigger.click();
469
+ await waitFor(() => !parentPanel.hidden);
470
+ childTrigger.focus();
471
+ childTrigger.click();
472
+ await waitFor(() => !childPanel.hidden);
473
+ const input = element.querySelector("#inner-filter");
474
+ input.focus();
475
+ element.querySelector("#inner-apply").click();
476
+ await waitFor(() => parentTrigger.textContent === "Dataset selected");
477
+ assert.equal(actions.length, 1);
478
+ assert.equal(element.querySelector("#inner-filter"), input);
479
+ assert.equal(document.activeElement, input);
480
+ assert.equal(childPanel.hidden, false, "The nested panel stays open across the Python rerender.");
481
+ input.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
482
+ await waitFor(() => childPanel.hidden);
483
+ assert.equal(parentPanel.hidden, false, "Escape first closes only the deepest panel.");
484
+ assert.equal(document.activeElement, childTrigger);
485
+ assert.equal(parentTrigger.getAttribute("aria-expanded"), "true");
486
+ assert.equal(childTrigger.getAttribute("aria-expanded"), "false");
487
+ childTrigger.dispatchEvent(new dom.window.KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
488
+ await waitFor(() => parentPanel.hidden);
489
+ assert.equal(document.activeElement, parentTrigger);
490
+ assert.equal(parentTrigger.getAttribute("aria-expanded"), "false");
491
+ });
src/agilab/lib/agi-web/frontend/agilab_react_python_host.css CHANGED
@@ -19,3 +19,5 @@
19
  .py-options input {flex-shrink:0}
20
  .py-app figure img {display:block}
21
  .py-table-pagination{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;padding:.5rem 0}.py-table-pagination [role=status]{font-variant-numeric:tabular-nums}
 
 
 
19
  .py-options input {flex-shrink:0}
20
  .py-app figure img {display:block}
21
  .py-table-pagination{display:flex;align-items:center;gap:.75rem;flex-wrap:wrap;padding:.5rem 0}.py-table-pagination [role=status]{font-variant-numeric:tabular-nums}
22
+
23
+ .py-popover{position:relative;width:fit-content}.py-popover-trigger{width:100%;text-align:left;overflow-wrap:anywhere}.py-popover-trigger[aria-expanded=true]{border-color:var(--py-accent)}.py-popover-trigger:focus-visible{outline:2px solid var(--py-accent);outline-offset:2px}.py-popover-panel{position:absolute;inset-block-start:calc(100% + .4rem);inset-inline-start:0;z-index:50;width:100%;max-width:34rem;box-sizing:border-box;max-height:70vh;overflow:auto;padding:1rem;border:1px solid var(--py-border);border-radius:.65rem;background:var(--py-surface);box-shadow:0 .5rem 1.5rem #18334826}.py-popover-panel[hidden]{display:none}
src/agilab/lib/agi-web/frontend/agilab_react_python_host.jsx CHANGED
@@ -246,6 +246,45 @@ function LinkAction({node}) {
246
  return <PythonLinkAction node={node} view={useContext(View)}/>;
247
  }
248
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  // Pure traversal stays outside View so local controls do not repaint heavy siblings.
250
  const Node = memo(function Node({node}) {
251
  const p = node.props, content = <Nodes nodes={node.children}/>;
@@ -253,6 +292,7 @@ const Node = memo(function Node({node}) {
253
  if (node.kind === "component") return <Island node={node}/>;
254
  if (node.kind === "columns") return <div className="py-columns" style={{gridTemplateColumns: p.weights.map(weight => `${weight}fr`).join(" ")}}>{content}</div>;
255
  if (node.kind === "tabs") return <Tabs node={node}/>;
 
256
  if (node.kind === "expander" || node.kind === "status") return <details className="py-expander" data-widget-kind={node.kind} data-widget-key={p.key} data-state={p.state} open={p.expanded}><summary>{p.label}</summary>{content}</details>;
257
  if (node.kind === "form") return <Form node={node}/>;
258
  if (node.kind === "dialog") return <div className="py-dialog-backdrop"><section role="dialog" aria-modal="true" aria-label={p.title}><h2>{p.title}</h2>{content}</section></div>;
 
246
  return <PythonLinkAction node={node} view={useContext(View)}/>;
247
  }
248
 
249
+ function Popover({node}) {
250
+ const p = node.props, [open, setOpen] = useState(false), element = useRef(null), trigger = useRef(null), panel = useRef(null);
251
+ const visible = open && !p.disabled, panelId = `${node.id}-popover`, helpId = `${node.id}-help`;
252
+ useEffect(() => {
253
+ if (!visible) return;
254
+ const outside = event => { if (!element.current?.contains(event.target)) setOpen(false); };
255
+ const escape = event => {
256
+ // A nested panel handles Escape first, even when its document listener
257
+ // was registered after ours. Hidden panels may still await effect cleanup.
258
+ if (event.key === "Escape" && panel.current && !panel.current.closest("[hidden]")
259
+ && !Array.from(panel.current.querySelectorAll(".py-popover-panel:not([hidden])"))
260
+ .some(child => !child.closest("[hidden]"))) {
261
+ event.preventDefault();
262
+ event.stopPropagation();
263
+ setOpen(false);
264
+ trigger.current?.focus();
265
+ }
266
+ };
267
+ document.addEventListener("pointerdown", outside);
268
+ document.addEventListener("focusin", outside);
269
+ document.addEventListener("keydown", escape);
270
+ return () => {
271
+ document.removeEventListener("pointerdown", outside);
272
+ document.removeEventListener("focusin", outside);
273
+ document.removeEventListener("keydown", escape);
274
+ };
275
+ }, [visible]);
276
+ return <div ref={element} className="py-popover" data-widget-kind="popover" data-widget-key={p.key}
277
+ style={{width: p.width === "stretch" ? "100%" : typeof p.width === "number" ? p.width : undefined}}>
278
+ <button ref={trigger} type="button" className="py-popover-trigger" disabled={p.disabled} title={p.help || undefined}
279
+ aria-expanded={visible} aria-controls={panelId} aria-haspopup="dialog" aria-describedby={p.help ? helpId : undefined}
280
+ onClick={() => setOpen(value => !value)}>{p.label}</button>
281
+ {p.help ? <span id={helpId} hidden>{p.help}</span> : null}
282
+ <div ref={panel} id={panelId} className="py-popover-panel" role="dialog" aria-label={p.label} hidden={!visible}>
283
+ <Nodes nodes={node.children}/>
284
+ </div>
285
+ </div>;
286
+ }
287
+
288
  // Pure traversal stays outside View so local controls do not repaint heavy siblings.
289
  const Node = memo(function Node({node}) {
290
  const p = node.props, content = <Nodes nodes={node.children}/>;
 
292
  if (node.kind === "component") return <Island node={node}/>;
293
  if (node.kind === "columns") return <div className="py-columns" style={{gridTemplateColumns: p.weights.map(weight => `${weight}fr`).join(" ")}}>{content}</div>;
294
  if (node.kind === "tabs") return <Tabs node={node}/>;
295
+ if (node.kind === "popover") return <Popover node={node}/>;
296
  if (node.kind === "expander" || node.kind === "status") return <details className="py-expander" data-widget-kind={node.kind} data-widget-key={p.key} data-state={p.state} open={p.expanded}><summary>{p.label}</summary>{content}</details>;
297
  if (node.kind === "form") return <Form node={node}/>;
298
  if (node.kind === "dialog") return <div className="py-dialog-backdrop"><section role="dialog" aria-modal="true" aria-label={p.title}><h2>{p.title}</h2>{content}</section></div>;
src/agilab/lib/agi-web/pyproject.toml CHANGED
@@ -1,5 +1,5 @@
1
  [project]
2
- version = "2026.10.08"
3
  name = "agi-web"
4
  description = "AGILAB portable web components with native React Python views and shared analysis components for Jupyter"
5
  requires-python = ">=3.12"
 
1
  [project]
2
+ version = "2026.10.10.1"
3
  name = "agi-web"
4
  description = "AGILAB portable web components with native React Python views and shared analysis components for Jupyter"
5
  requires-python = ">=3.12"
src/agilab/lib/agi-web/src/agi_web/python_ui.py CHANGED
@@ -151,6 +151,13 @@ def expander(label, expanded=False, **kwargs):
151
  return Container(_node("expander", label=str(label), expanded=expanded, **kwargs))
152
 
153
 
 
 
 
 
 
 
 
154
  def form(key, *, clear_on_submit=False, **kwargs):
155
  if current_session().form_stack:
156
  raise UIError("Forms cannot be nested.")
@@ -271,7 +278,8 @@ def select_slider(label, options, value=None, **kwargs):
271
  def number_input(label, min_value=None, max_value=None, value="min", step=None, **kwargs):
272
  if value == "min":
273
  value = min_value if min_value is not None else (0.0 if isinstance(step, float) else 0)
274
- integer = isinstance(value, int) and not isinstance(value, bool)
 
275
  return _widget("number_input", label, value, min_value=min_value, max_value=max_value,
276
  step=step or (1 if integer else 0.01), integer=integer,
277
  decoder=int if integer else float, **kwargs)
 
151
  return Container(_node("expander", label=str(label), expanded=expanded, **kwargs))
152
 
153
 
154
+ def popover(label, *, help=None, disabled=False, width="content", key=None, **kwargs):
155
+ return Container(current_session().add_node(
156
+ "popover", {"label": str(label), "help": help, "disabled": bool(disabled),
157
+ "width": width, "key": key, **kwargs}, key=key,
158
+ ))
159
+
160
+
161
  def form(key, *, clear_on_submit=False, **kwargs):
162
  if current_session().form_stack:
163
  raise UIError("Forms cannot be nested.")
 
278
  def number_input(label, min_value=None, max_value=None, value="min", step=None, **kwargs):
279
  if value == "min":
280
  value = min_value if min_value is not None else (0.0 if isinstance(step, float) else 0)
281
+ numeric_types = [item for item in ((min_value, max_value, step) if value is None else (value,)) if item is not None]
282
+ integer = bool(numeric_types) and all(isinstance(item, int) and not isinstance(item, bool) for item in numeric_types)
283
  return _widget("number_input", label, value, min_value=min_value, max_value=max_value,
284
  step=step or (1 if integer else 0.01), integer=integer,
285
  decoder=int if integer else float, **kwargs)
src/agilab/lib/agi-web/src/agi_web/react_python_host_assets/agilab_react_python_host.css CHANGED
The diff for this file is too large to render. See raw diff
 
src/agilab/lib/agi-web/src/agi_web/react_python_host_assets/agilab_react_python_host.js CHANGED
The diff for this file is too large to render. See raw diff
 
src/agilab/lib/agi-web/test/test_python_ui_rendering_and_input_contracts.py CHANGED
@@ -10,6 +10,7 @@ import pytest
10
 
11
  from agi_web import python_ui as ui
12
  from agi_web.python_view_session import RerunView, UIError, ViewSession, use_session
 
13
 
14
 
15
  @pytest.fixture
@@ -83,6 +84,44 @@ def test_text_and_status_helpers_preserve_visible_content(session):
83
  assert ui.status("Done", state="complete").node["props"]["state"] == "complete"
84
 
85
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  def test_duplicate_control_keys_and_numeric_defaults(session):
87
  assert ui.toggle("Toggle", key="toggle") is False
88
  assert ui.text_area("Notes", value="kept", key="notes") == "kept"
@@ -407,3 +446,90 @@ def test_component_duplicate_keys_config_and_private_secret_files(session, monke
407
  assert ui.secrets.fixture == ui.secrets["fixture"] == "synthetic fixture value"
408
  with pytest.raises(AttributeError):
409
  _ = ui.secrets.missing
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
10
 
11
  from agi_web import python_ui as ui
12
  from agi_web.python_view_session import RerunView, UIError, ViewSession, use_session
13
+ from agi_web.testing import AppTest
14
 
15
 
16
  @pytest.fixture
 
84
  assert ui.status("Done", state="complete").node["props"]["state"] == "complete"
85
 
86
 
87
+ @pytest.mark.parametrize(
88
+ "bounds, entered, expected_type, expected_step",
89
+ [
90
+ ({"min_value": 0, "max_value": 4}, 4, int, 1),
91
+ ({"max_value": 4}, 2, int, 1),
92
+ ({"step": 2}, 4, int, 2),
93
+ ({"min_value": 0.0, "max_value": 4.0}, 1.5, float, 0.01),
94
+ ({"min_value": 0, "max_value": 4.0}, 1.5, float, 0.01),
95
+ ({"min_value": 0, "max_value": 4, "step": 0.5}, 1.5, float, 0.5),
96
+ ({}, 1.5, float, 0.01),
97
+ ],
98
+ )
99
+ def test_empty_numeric_control_infers_type_and_round_trips_native_input(
100
+ bounds, entered, expected_type, expected_step
101
+ ):
102
+ app = AppTest.from_function(
103
+ lambda: ui.number_input("Score", value=None, key="score", **bounds)
104
+ ).run()
105
+ assert not app.exception
106
+ assert app.number_input("score").value is None
107
+ assert app.number_input("score").proto.integer is (expected_type is int)
108
+ assert app.number_input("score").step == expected_step
109
+ app.number_input("score").set_value(entered).run()
110
+ assert not app.exception
111
+ assert type(app.session_state["score"]) is expected_type
112
+ assert app.session_state["score"] == entered
113
+
114
+
115
+ @pytest.mark.parametrize("invalid", [1.5, True, False])
116
+ def test_empty_integer_control_rejects_fractional_and_boolean_native_input(invalid):
117
+ app = AppTest.from_function(
118
+ lambda: ui.number_input("Score", 0, 4, value=None, key="score")
119
+ ).run()
120
+ with pytest.raises(UIError, match="Expected (an integer|a number)"):
121
+ app.number_input("score").set_value(invalid).run()
122
+ assert app.session_state["score"] is None
123
+
124
+
125
  def test_duplicate_control_keys_and_numeric_defaults(session):
126
  assert ui.toggle("Toggle", key="toggle") is False
127
  assert ui.text_area("Notes", value="kept", key="notes") == "kept"
 
446
  assert ui.secrets.fixture == ui.secrets["fixture"] == "synthetic fixture value"
447
  with pytest.raises(AttributeError):
448
  _ = ui.secrets.missing
449
+
450
+
451
+ def test_popover_context_and_container_passthrough_keep_children_scoped(session):
452
+ outer = ui.container(key="file-picker")
453
+ with outer.popover("Choose dataset", help="Choose a CSV", width="stretch", key="datasets"):
454
+ ui.text_input("Filter files", key="dataset-filter")
455
+ with ui.popover("Advanced", disabled=True):
456
+ ui.caption("Nested details")
457
+ ui.text("Outside")
458
+ disclosure = outer.node["children"][0]
459
+ assert disclosure["kind"] == "popover"
460
+ assert disclosure["props"]["label"] == "Choose dataset"
461
+ assert disclosure["props"]["help"] == "Choose a CSV"
462
+ assert disclosure["props"]["width"] == "stretch"
463
+ assert disclosure["children"][0]["kind"] == "text_input"
464
+ assert disclosure["children"][1]["kind"] == "popover"
465
+ assert disclosure["children"][1]["props"]["disabled"] is True
466
+ assert disclosure["children"][1]["children"][0]["kind"] == "caption"
467
+ assert session.roots["main"][-1]["props"]["body"] == "Outside"
468
+ assert len(session.stack) == 1
469
+
470
+
471
+ def test_real_file_picker_csv_controls_select_filter_roots_manual_path_and_upload(tmp_path):
472
+ from agi_gui.file_picker import agi_file_picker
473
+
474
+ first = tmp_path / "datasets"
475
+ second = tmp_path / "exports"
476
+ first.mkdir()
477
+ second.mkdir()
478
+ dataset = first / "input_1000_rows.csv"
479
+ dataset.write_text("x,y\n" + "".join(f"{row},{row * 2}\n" for row in range(1_000)))
480
+ exported = second / "export.csv"
481
+ exported.write_text("x,y\n1,2\n")
482
+
483
+ def page():
484
+ ui.session_state["selected_csv"] = agi_file_picker(
485
+ "Dataset", key="dataset-picker", roots={"Datasets": first, "Exports": second},
486
+ patterns="*.csv", allow_dirs=False, container=ui.container(),
487
+ allow_upload=True, upload_dir=first, upload_types=["csv"],
488
+ )
489
+
490
+ app = AppTest.from_function(page).run()
491
+ assert not app.exception
492
+ assert len(app.get("popover")) == 1
493
+ assert app.get("pills")[0].value == "Datasets"
494
+ table = app.get("dataframe")[0]
495
+ assert len(table.node["props"]["rows"]) == 1
496
+ table.set_value(ui.SessionState(selection=ui.SessionState(rows=[0], columns=[]))).run()
497
+ assert not app.exception
498
+ assert app.session_state["selected_csv"] == str(dataset)
499
+ app.get("text_input")[0].input("no matching CSV").run()
500
+ assert not app.exception
501
+ assert not app.get("dataframe")
502
+ assert any("No matching files" in item.value for item in app.get("caption"))
503
+ app.get("text_input")[0].input("").run()
504
+ app.get("pills")[0].select("Exports").run()
505
+ assert not app.exception
506
+ assert app.get("dataframe")[0].node["props"]["rows"][0][0] == "export.csv"
507
+ app.get("text_input")[1].input(str(exported)).run()
508
+ next(item for item in app.get("button") if item.label == "Use path").click().run()
509
+ assert not app.exception
510
+ assert app.session_state["selected_csv"] == str(exported)
511
+ app.get("pills")[0].select("Datasets").run()
512
+ app.get("file_uploader")[0].upload("uploaded.csv", b"x,y\n3,6\n", "text/csv").run()
513
+ assert not app.exception
514
+ uploaded = first / "uploaded.csv"
515
+ assert uploaded.read_bytes() == b"x,y\n3,6\n"
516
+ assert app.session_state["selected_csv"] == str(uploaded)
517
+ assert app.session_state["dataset-picker:upload"] == []
518
+ app.run()
519
+ assert not app.exception
520
+ assert app.session_state["selected_csv"] == str(uploaded)
521
+ app.get("file_uploader")[0].upload("next_upload.csv", b"x,y\n4,8\n", "text/csv").run()
522
+ assert not app.exception
523
+ next_upload = first / "next_upload.csv"
524
+ assert next_upload.read_bytes() == b"x,y\n4,8\n"
525
+ assert app.session_state["selected_csv"] == str(next_upload)
526
+ assert uploaded.read_bytes() == b"x,y\n3,6\n"
527
+ app.get("dataframe")[0].set_value(ui.SessionState(selection=ui.SessionState(rows=[0], columns=[]))).run()
528
+ assert app.session_state["selected_csv"] == str(dataset)
529
+ app.get("text_input")[1].input(str(next_upload)).run()
530
+ next(item for item in app.get("button") if item.label == "Use path").click().run()
531
+ assert not app.exception
532
+ assert app.session_state["selected_csv"] == str(next_upload)
533
+ app.run()
534
+ assert not app.exception
535
+ assert app.session_state["selected_csv"] == str(next_upload)
src/agilab/notebooks/notebook_export_support.py CHANGED
@@ -18,7 +18,7 @@ import tomllib
18
  from agi_env.app_provider_registry import app_name_aliases
19
  from agi_env.app_settings_support import read_app_settings
20
 
21
- from agilab.notebooks.notebook_helper_cell import _helper_cell
22
 
23
  from agilab.ui.page_bundle_registry import discover_page_bundle
24
 
@@ -1732,6 +1732,10 @@ def build_notebook_export_handoff_markdown(manifest: Mapping[str, Any]) -> str:
1732
  "3. Run `run_agilab_stage(i)` for one stage, or `run_agilab_pipeline()` for the full workflow.",
1733
  "4. Edit `STAGE_###_CODE` cells when you want the notebook to become the new source of truth.",
1734
  "",
 
 
 
 
1735
  "## Commands",
1736
  "",
1737
  "```bash",
@@ -2272,6 +2276,7 @@ def build_notebook_document(
2272
  f"- Export mode: `{export_context.export_mode}`",
2273
  "- First run `validate_agilab_export()` to check local paths before executing workflow code.",
2274
  "- Use `run_agilab_stage(i)` or `run_agilab_pipeline()` to execute workflow stages in their recorded runtime.",
 
2275
  "- Disabled/skipped stages and output-skip rules remain enforced by the exported runner helpers.",
2276
  "- The selected automation profile is applied; module max_workers is preserved for re-import, while notebook pipeline execution stays sequential.",
2277
  "- ID-less stages can be re-imported only while the stage at the recorded module/index still matches its export fingerprint. Notebook source edits are accepted; if the AGILAB target changed, refresh the export or add an explicit stage ID.",
 
18
  from agi_env.app_provider_registry import app_name_aliases
19
  from agi_env.app_settings_support import read_app_settings
20
 
21
+ from agilab.notebooks.notebook_helper_cell import SDK_WORKER_DEPLOYMENT_HANDOFF, _helper_cell
22
 
23
  from agilab.ui.page_bundle_registry import discover_page_bundle
24
 
 
1732
  "3. Run `run_agilab_stage(i)` for one stage, or `run_agilab_pipeline()` for the full workflow.",
1733
  "4. Edit `STAGE_###_CODE` cells when you want the notebook to become the new source of truth.",
1734
  "",
1735
+ "## SDK deployment prerequisites",
1736
+ "",
1737
+ SDK_WORKER_DEPLOYMENT_HANDOFF,
1738
+ "",
1739
  "## Commands",
1740
  "",
1741
  "```bash",
 
2276
  f"- Export mode: `{export_context.export_mode}`",
2277
  "- First run `validate_agilab_export()` to check local paths before executing workflow code.",
2278
  "- Use `run_agilab_stage(i)` or `run_agilab_pipeline()` to execute workflow stages in their recorded runtime.",
2279
+ "- " + SDK_WORKER_DEPLOYMENT_HANDOFF,
2280
  "- Disabled/skipped stages and output-skip rules remain enforced by the exported runner helpers.",
2281
  "- The selected automation profile is applied; module max_workers is preserved for re-import, while notebook pipeline execution stays sequential.",
2282
  "- ID-less stages can be re-imported only while the stage at the recorded module/index still matches its export fingerprint. Notebook source edits are accepted; if the AGILAB target changed, refresh the export or add an explicit stage ID.",
src/agilab/notebooks/notebook_helper_cell.py CHANGED
@@ -8,9 +8,8 @@
8
  `_helper_cell` is a single ~1250-line f-string holding the Python that exported
9
  notebooks run to locate their app, rebuild stage context, and render analysis
10
  pages. It carried a third of notebook_export_support.py while sharing nothing
11
- with it -- the only value interpolated from the caller is `payload_literal`, and
12
- every other brace in the template is `{{`-escaped and belongs to the generated
13
- code.
14
 
15
  Keeping it here leaves the export logic readable and lets this file be reviewed
16
  as what it is: emitted source, not control flow.
@@ -23,6 +22,14 @@ import textwrap
23
  from typing import Any
24
 
25
 
 
 
 
 
 
 
 
 
26
  def _helper_cell(payload: dict[str, Any]) -> str:
27
  payload_literal = repr(json.dumps(payload, ensure_ascii=False))
28
  return textwrap.dedent(
@@ -357,6 +364,10 @@ def _helper_cell(payload: dict[str, Any]) -> str:
357
  "3. Render related analysis pages if configured.",
358
  "4. If you edit stage code, re-import the notebook into AGILAB when you want lab_stages.toml to become the source of truth again.",
359
  "",
 
 
 
 
360
  "## Paths",
361
  "",
362
  "- Artifact directory: `" + str(AGILAB_NOTEBOOK_EXPORT.get("artifact_dir") or "(not set)") + "`",
@@ -988,6 +999,10 @@ def _helper_cell(payload: dict[str, Any]) -> str:
988
  inherited_mode = run_args.pop("mode", None)
989
  if inherited_mode not in (None, ""):
990
  run_mode = inherited_mode
 
 
 
 
991
  run_params = dict(run_args)
992
  run_stages_payload = run_params.pop("stages", []) or []
993
  if "args" in run_params:
 
8
  `_helper_cell` is a single ~1250-line f-string holding the Python that exported
9
  notebooks run to locate their app, rebuild stage context, and render analysis
10
  pages. It carried a third of notebook_export_support.py while sharing nothing
11
+ with it -- the caller payload and the shared deployment instructions are embedded
12
+ as literals. Braces belonging to the generated Python are `{{`-escaped.
 
13
 
14
  Keeping it here leaves the export logic readable and lets this file be reviewed
15
  as what it is: emitted source, not control flow.
 
22
  from typing import Any
23
 
24
 
25
+ SDK_WORKER_DEPLOYMENT_HANDOFF = (
26
+ "SDK run stages using `AGI.run` (including app shorthand) need deployed manager and worker environments, "
27
+ "including mode `0` (local Python). Before running them, use **ORCHESTRATE → Resources → Deploy scheduler & workers**, "
28
+ "or include an explicit `AGI.install` stage in your plan. Export preserves your plan and does not add an installation stage. "
29
+ "Python stages that do not call `AGI.run` remain independently editable and executable with their own dependencies."
30
+ )
31
+
32
+
33
  def _helper_cell(payload: dict[str, Any]) -> str:
34
  payload_literal = repr(json.dumps(payload, ensure_ascii=False))
35
  return textwrap.dedent(
 
364
  "3. Render related analysis pages if configured.",
365
  "4. If you edit stage code, re-import the notebook into AGILAB when you want lab_stages.toml to become the source of truth again.",
366
  "",
367
+ "## SDK deployment prerequisites",
368
+ "",
369
+ {SDK_WORKER_DEPLOYMENT_HANDOFF!r},
370
+ "",
371
  "## Paths",
372
  "",
373
  "- Artifact directory: `" + str(AGILAB_NOTEBOOK_EXPORT.get("artifact_dir") or "(not set)") + "`",
 
999
  inherited_mode = run_args.pop("mode", None)
1000
  if inherited_mode not in (None, ""):
1001
  run_mode = inherited_mode
1002
+ # Older Workflow templates used this label for SDK local Python.
1003
+ # Other values keep the SDK's own validation and mode semantics.
1004
+ if run_mode == "local":
1005
+ run_mode = 0
1006
  run_params = dict(run_args)
1007
  run_stages_payload = run_params.pop("stages", []) or []
1008
  if "args" in run_params: