From f11cec9d487a9b14402188e051dea86018853d92 Mon Sep 17 00:00:00 2001 From: giles Date: Sun, 11 Jan 2026 09:30:21 +0000 Subject: [PATCH] Add SPA-style navigation for plan nodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add /run/{run_id}/plan/node/{step_id} endpoint for node details - Node click updates URL without full page reload (pushState) - Browser back/forward works correctly - Refreshing page preserves selected node via ?node= parameter - Node details loaded via fetch with partial HTML response 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- server.py | 321 +++++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 234 insertions(+), 87 deletions(-) diff --git a/server.py b/server.py index f494bce..f82ff75 100644 --- a/server.py +++ b/server.py @@ -1335,8 +1335,159 @@ PLAN_CACHE_DIR = CACHE_DIR / 'plans' ANALYSIS_CACHE_DIR = CACHE_DIR / 'analysis' +@app.get("/run/{run_id}/plan/node/{step_id}", response_class=HTMLResponse) +async def run_plan_node_detail(run_id: str, step_id: str, request: Request): + """HTMX partial: Get node detail HTML fragment.""" + ctx = await get_user_context_from_cookie(request) + if not ctx: + return HTMLResponse('

Login required

', status_code=401) + + run = await asyncio.to_thread(load_run, run_id) + if not run: + return HTMLResponse(f'

Run not found

', status_code=404) + + # Load plan data + plan_data = None + PLAN_CACHE_DIR.mkdir(parents=True, exist_ok=True) + for plan_file in PLAN_CACHE_DIR.glob("*.json"): + try: + with open(plan_file) as f: + data = json.load(f) + plan_inputs = data.get("input_hashes", {}) + if set(plan_inputs.values()) == set(run.inputs): + plan_data = data + break + except (json.JSONDecodeError, IOError): + continue + + if not plan_data: + return HTMLResponse('

Plan not found

') + + # Find the step + step = None + for s in plan_data.get("steps", []): + if s.get("step_id") == step_id: + step = s + break + + if not step: + return HTMLResponse(f'

Step {step_id} not found

') + + # Get step info + step_name = step.get("name", step_id[:20]) + node_type = step.get("node_type", "EFFECT") + cache_id = step.get("cache_id", "") + config = step.get("config", {}) + level = step.get("level", 0) + input_steps = step.get("input_steps", []) + + # Check for IPFS CID + step_cid = None + if run.step_results: + res = run.step_results.get(step_id) + if isinstance(res, dict) and res.get("cid"): + step_cid = res["cid"] + + has_cached = cache_manager.has_content(cache_id) if cache_id else False + color = NODE_COLORS.get(node_type, NODE_COLORS["default"]) + + # Build preview HTML + preview_html = "" + if has_cached and cache_id: + media_type = detect_media_type(get_cache_path(cache_id)) + if media_type == "video": + preview_html = f''' +
+ +
''' + elif media_type == "image": + preview_html = f''' +
+ +
''' + elif step_cid: + ipfs_gateway = IPFS_GATEWAY_URL.rstrip('/') if IPFS_GATEWAY_URL else "https://ipfs.io/ipfs" + preview_html = f''' +
+ +
''' + + # Build output link + output_html = "" + if step_cid: + output_html = f''' +
+
Output (IPFS)
+ + {step_cid} + View + +
''' + elif has_cached and cache_id: + output_html = f''' + ''' + + # Config display + config_html = "" + if config: + config_json = json.dumps(config, indent=2) + config_html = f''' +
+
Config
+
{config_json}
+
''' + + # Input steps + inputs_html = "" + if input_steps: + inputs_list = "".join([ + f'{inp[:16]}...' + for inp in input_steps + ]) + inputs_html = f''' +
+
Input Steps
+
{inputs_list}
+
''' + + status = "cached" if (has_cached or step_cid) else ("completed" if run.status == "completed" else "pending") + status_color = "green" if status in ("cached", "completed") else "yellow" + + return HTMLResponse(f''' +
+
+

{step_name}

+
+ {node_type} + {status} + Level {level} +
+
+ +
+ {preview_html} +
+
Step ID: {step_id}
+
Cache ID: {cache_id[:32] if cache_id else "N/A"}...
+
+ {output_html} + {inputs_html} + {config_html} + ''') + + @app.get("/run/{run_id}/plan", response_class=HTMLResponse) -async def run_plan_visualization(run_id: str, request: Request): +async def run_plan_visualization(run_id: str, request: Request, node: Optional[str] = None): """Visualize execution plan as interactive DAG.""" ctx = await get_user_context_from_cookie(request) if not ctx: @@ -1504,7 +1655,7 @@ async def run_plan_visualization(run_id: str, request: Request): nodes_json = json.dumps(nodes) edges_json = json.dumps(edges) - dag_html = render_dag_cytoscape(nodes_json, edges_json) + dag_html = render_dag_cytoscape(nodes_json, edges_json, run_id=run_id, initial_node=node or "") # Stats summary - count from built nodes to reflect actual execution status total = len(nodes) @@ -4712,34 +4863,82 @@ def render_run_sub_tabs(run_id: str, active: str = "overview") -> str: return html -def render_dag_cytoscape(nodes_json: str, edges_json: str, container_id: str = "cy") -> str: - """Render Cytoscape.js DAG visualization HTML with WebSocket-ready architecture.""" +def render_dag_cytoscape(nodes_json: str, edges_json: str, container_id: str = "cy", run_id: str = "", initial_node: str = "") -> str: + """Render Cytoscape.js DAG visualization HTML with HTMX SPA-style navigation.""" return f'''
-