fix(nav): switch_page resolves correctly + bottom-of-page back link

Two issues, same fix surface.

(1) Reported crash on Back-to-Home:

    StreamlitAPIException: Could not find page: app.py.

``st.switch_page("app.py")`` doesn't work under ``st.navigation`` —
the entry script is the nav manager itself and is not a registered
page. The fix needs to pass an ``st.Page`` object whose script
identity matches one registered in the nav.

First-pass attempt (``from src.gui.app import _home_page``) hit a
worse failure: importing ``app.py`` from inside a tool-page render
re-executes the nav setup with the WRONG "main script" context, so
every ``st.Page("pages/N_foo.py", ...)`` call in ``_build_navigation``
fails with "file could not be found".

Extract the home renderer into its own module ``src/gui/_home.py``
which has no top-level Streamlit side effects. Both the nav manager
and the back-link helper import ``_home_page`` from there. The Page
object built at click time has the same callable identity as the one
registered, so ``st.switch_page`` resolves it.

(2) Reported UX: the back button scrolled out of view on long pages.

Add a second ``back_to_home_link(key="_back_to_home_link_bottom")``
call near the footer of every tool page (1-9). The unique key avoids
widget-id collision with the top instance. Coming-Soon stubs get it
unconditionally; Ready tools render it only after a result exists
because the page short-circuits with ``st.stop()`` before then —
when no result is on screen the page is short enough that the top
link is sufficient.

2220 tests pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-16 23:58:33 +00:00
parent 42f8d78dd5
commit 21fd8a4cd7
12 changed files with 177 additions and 120 deletions

131
src/gui/_home.py Normal file
View File

@@ -0,0 +1,131 @@
"""Home-page renderer extracted into its own module.
This used to live inside ``src/gui/app.py`` as a local function. Pulling
it out into a side-effect-free module lets the ``back_to_home_link``
helper (in ``components/_legacy.py``) import the home callable to pass
into ``st.switch_page`` — without re-running ``app.py``'s navigation
setup, which would itself blow up because tool pages have a different
"main script" context that breaks the registry's relative ``pages/…``
paths.
Keep this module imports-light: nothing that runs Streamlit commands
at module top level, nothing that triggers config loads. Just the
``_home_page`` callable.
"""
from __future__ import annotations
import streamlit as st
def _home_page() -> None:
"""Render the home page — multi-file upload + per-file analysis."""
from src.gui.components import hide_streamlit_chrome, render_findings_panel
from src.gui.components._legacy import _run_analysis_on_upload
from src.i18n import t
st.set_page_config(
page_title=t("home.page_title"),
page_icon="🧹",
layout="wide",
)
hide_streamlit_chrome()
st.title(t("home.title"))
st.caption(t("home.caption"))
st.divider()
st.markdown(f"### {t('upload.heading')}")
st.caption(t("upload.intro_multi"))
uploaded_files = st.file_uploader(
t("upload.uploader_label_multi"),
type=["csv", "tsv", "xlsx", "xls"],
accept_multiple_files=True,
key="home_upload",
help=t("upload.uploader_help"),
)
if not uploaded_files:
st.info(t("upload.empty_state"))
return
# Keep tool pages working: they consume a single ``home_uploaded_*``
# set via ``pickup_or_upload``. Expose the first uploaded file as
# the "active" upload for that contract; the rest live alongside
# for per-file analysis on this page.
first = uploaded_files[0]
if (
st.session_state.get("home_uploaded_name") != first.name
or st.session_state.get("home_uploaded_size") != first.size
):
st.session_state["home_uploaded_name"] = first.name
st.session_state["home_uploaded_size"] = first.size
st.session_state["home_uploaded_bytes"] = first.getvalue()
# Per-file findings live in a dict so removing a file from the
# uploader (Streamlit's "x" button) drops its results too. We only
# re-analyze files we haven't already analyzed in this session.
findings_by_file: dict = st.session_state.setdefault(
"home_findings_by_file", {}
)
current_names = {f.name for f in uploaded_files}
findings_by_file = {
name: result for name, result in findings_by_file.items()
if name in current_names
}
st.session_state["home_findings_by_file"] = findings_by_file
pending = [f for f in uploaded_files if f.name not in findings_by_file]
col_run, col_clear, _ = st.columns([1, 1, 4])
with col_run:
run_clicked = st.button(
t("upload.run_button"),
type="primary",
key="home_run_analysis",
disabled=not pending,
use_container_width=True,
)
with col_clear:
clear_clicked = st.button(
t("upload.clear_results"),
key="home_clear_results",
disabled=not findings_by_file,
use_container_width=True,
)
if clear_clicked:
st.session_state["home_findings_by_file"] = {}
st.rerun()
if run_clicked:
progress = st.progress(0.0, text=t("upload.scanning"))
for i, f in enumerate(pending, start=1):
findings_by_file[f.name] = _run_analysis_on_upload(f)
progress.progress(i / len(pending), text=f"{f.name}")
st.session_state["home_findings_by_file"] = findings_by_file
progress.empty()
st.rerun()
if findings_by_file:
st.divider()
# Preserve uploader order so the user sees results in the same
# order they appear in the file list above. Each file's findings
# render via ``render_findings_panel`` so the per-tool grouping
# (and the "Open <Tool>" jump link under each group) is kept —
# that's how the user reaches the cleaner that fixes a specific
# finding without hunting through the sidebar.
for f in uploaded_files:
if f.name not in findings_by_file:
continue
findings = findings_by_file[f.name]
with st.container(border=True):
if not findings:
st.markdown(f"### 📄 {f.name}")
st.success(t("findings.none"))
else:
render_findings_panel(findings, header=f"📄 {f.name}")
st.divider()
st.caption(t("chrome.footer"))