From 439b736e30bd35d8bf2e94de786c79fefd642b24 Mon Sep 17 00:00:00 2001 From: abccodes Date: Fri, 26 Jun 2026 06:06:18 -0700 Subject: [PATCH] [aidan] feat/workflows-browser: spawn run browser cards on the dashboard the renderer is showing --- backend/apps/agents/core/ws_manager.py | 13 +++++++++++ backend/apps/workflows/executor.py | 22 ++++++++++++++++++- backend/main.py | 4 ++++ .../hooks/lifecycle/useDashboardLifecycle.ts | 8 +++++++ 4 files changed, 46 insertions(+), 1 deletion(-) diff --git a/backend/apps/agents/core/ws_manager.py b/backend/apps/agents/core/ws_manager.py index bfe58269..d3e3aa27 100644 --- a/backend/apps/agents/core/ws_manager.py +++ b/backend/apps/agents/core/ws_manager.py @@ -1,6 +1,7 @@ import asyncio import json import logging +from typing import Optional from fastapi import WebSocket from backend.apps.agents.core.seq_log import TERMINAL_STATUSES, seq_log @@ -39,6 +40,9 @@ class ConnectionManager: def __init__(self): self.connections: dict[str, list[WebSocket]] = {} self.global_connections: list[WebSocket] = [] + # Which dashboard each global socket is currently showing, keyed by id(websocket). active_dashboard_id is the last one activated (the window the user is looking at most recently); a scheduled run targets it so its browser card spawns where the renderer can render it. + self.global_dashboard_ids: dict[int, str] = {} + self.active_dashboard_id: Optional[str] = None self.pending_futures: dict[str, asyncio.Future] = {} self.browser_futures: dict[str, asyncio.Future] = {} @@ -60,10 +64,19 @@ class ConnectionManager: if not self.connections[session_id]: del self.connections[session_id] + def set_active_dashboard(self, websocket: WebSocket, dashboard_id: str): + """Record which dashboard a renderer is showing; last activation wins.""" + self.global_dashboard_ids[id(websocket)] = dashboard_id + self.active_dashboard_id = dashboard_id + def disconnect_global(self, websocket: WebSocket): self.global_connections = [ ws for ws in self.global_connections if ws != websocket ] + # Drop this socket's active-dashboard pointer; if it owned the global one, fall back to any window still connected so a closed tab doesn't leave a stale target. + self.global_dashboard_ids.pop(id(websocket), None) + if self.active_dashboard_id not in self.global_dashboard_ids.values(): + self.active_dashboard_id = next(iter(self.global_dashboard_ids.values()), None) async def send_to_session(self, session_id: str, event: str, data: dict): """Broadcast a session event with monotonic sequencing; terminal statuses also persist to disk.""" diff --git a/backend/apps/workflows/executor.py b/backend/apps/workflows/executor.py index 99911767..81ea5088 100644 --- a/backend/apps/workflows/executor.py +++ b/backend/apps/workflows/executor.py @@ -64,6 +64,26 @@ def _resolve_allowed_tools(wf: Workflow) -> Optional[list[str]]: return list(wf.actions.configured_sets) +def p_resolve_run_dashboard_id(wf: Workflow) -> Optional[str]: + """Pick the dashboard this run's agent attaches to, so browser tools work like in chat. + + Browser cards render only on the dashboard the renderer is currently showing, so we + prefer the live active dashboard over anything stored. Resolved fresh each fire (a + stored id goes stale the moment the user switches or deletes a dashboard). Last resort + is the most-recently-updated dashboard; None just means no browser this run.""" + if wf.dashboard_id: + return wf.dashboard_id + from backend.apps.agents.core.ws_manager import ws_manager + if ws_manager.active_dashboard_id: + return ws_manager.active_dashboard_id + from backend.apps.dashboards.dashboards import load_all + dashboards = load_all() + if dashboards: + dashboards.sort(key=lambda d: d.updated_at or d.created_at, reverse=True) + return dashboards[0].id + return None + + def p_make_remember_approval(workflow_id: str): def p_remember_approval(tool_name: str, behavior: str) -> None: fresh = storage.get_workflow(workflow_id) @@ -242,7 +262,7 @@ async def execute( allowed_tools=resolved_allowed_tools if resolved_allowed_tools is not None else [ "Read", "Edit", "Write", "Bash", "Glob", "Grep", "AskUserQuestion", ], - dashboard_id=wf.dashboard_id, + dashboard_id=p_resolve_run_dashboard_id(wf), ) session = await agent_manager.launch_agent(config) diff --git a/backend/main.py b/backend/main.py index 3eb9dc84..7905f39d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -355,6 +355,10 @@ async def websocket_dashboard(websocket: WebSocket): payload.get("request_id", ""), payload, ) + elif event == "dashboard:active": + dash_id = payload.get("dashboard_id") + if dash_id: + ws_manager.set_active_dashboard(websocket, dash_id) except WebSocketDisconnect: ws_manager.disconnect_global(websocket) diff --git a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts index d615c2c0..edb867e5 100644 --- a/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts +++ b/frontend/src/app/pages/Dashboard/hooks/lifecycle/useDashboardLifecycle.ts @@ -93,6 +93,12 @@ export function useDashboardLifecycle({ }; }, [dashboardId]); + // Tell the backend which dashboard is on screen, so a scheduled workflow run spawns its browser card on the dashboard the user can actually see. send queues until the socket opens, so firing before connect is fine. + useEffect(() => { + if (!dashboardId) return; + dashboardWs.send('dashboard:active', { dashboard_id: dashboardId }); + }, [dashboardId]); + useEffect(() => { if (!dashboardId) return; hasFittedRef.current = false; @@ -105,6 +111,8 @@ export function useDashboardLifecycle({ const cleanupBrowserHandler = initBrowserCommandHandler(); // Global broadcasts (spawned browser cards) skip the replay log, so a socket gap loses them; a reconnect refetch is the only way they return. const unsubReconnect = dashboardWs.on('dashboard:reconnected', () => { + // A socket gap drops the backend's active-dashboard pointer; re-assert it so scheduled-run browser cards still target this dashboard after a reconnect. + dashboardWs.send('dashboard:active', { dashboard_id: dashboardId }); dispatch(fetchSessions({ dashboardId })); dispatch(fetchLayout({ dashboardId, isReconnect: true })); // workflow:run/updated/deleted are global broadcasts that skip the replay log, so a socket gap drops them: refetch to heal stale "running" cards, ghost workflows, and missed run history on reconnect.