[aidan] feat/workflows-browser: spawn run browser cards on the dashboard the renderer is showing

This commit is contained in:
abccodes
2026-06-26 06:06:18 -07:00
parent e91c068654
commit 439b736e30
4 changed files with 46 additions and 1 deletions
+13
View File
@@ -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."""
+21 -1
View File
@@ -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)
+4
View File
@@ -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)
@@ -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.