Introduce DashboardManager with distinct modes to control behaviour across platforms

This commit is contained in:
Dominik Jain authored and Dominik Jain committed 2026-04-18 01:47:33 +02:00
1 parent 0da7b6d711
commit a386ec41c4
1 file changed
+160 -118
+160 -118
View File
@@ -12,6 +12,7 @@ import sys
from collections.abc import Callable, Iterator, Sequence
from contextlib import contextmanager
from datetime import datetime
from enum import Enum
from logging import Logger
from typing import TYPE_CHECKING, Optional, TypeVar
@@ -246,6 +247,147 @@ class ActiveModes:
return [SerenaAgentMode.load(mode_name) for mode_name in self._base_modes or []]
class DashboardManager:
class Mode(Enum):
BROWSER = "browser"
"""
Open the dashboard in the default browser; supported on all platforms.
"""
PYWEBVIEW = "webview"
"""
Open the dashboard via a native window (using pywebview) which minimises to the tray; supported on Windows only
(on macOS, tray apps for multiple instances accumulate in the top bar, which users do not want)
"""
TRAY_MANAGER = "tray_manager"
"""
Register dashboard instance with a central manager tray app (single tray icon for all instances),
spawning the tray manager if not already running; supported on macOS and Windows.
"""
@classmethod
def from_platform(cls) -> "DashboardManager.Mode":
match platform.system():
case "Windows":
return cls.PYWEBVIEW
case "Darwin":
return cls.TRAY_MANAGER
case _:
return cls.BROWSER
def __init__(self, port: int, host_listen_address: str, open_dashboard_on_launch: bool, active_project: Project | None = None):
self._port = port
self._mode = self.Mode.from_platform()
self._dashboard_viewer_process: multiprocessing.Process | None = None
dashboard_host = host_listen_address
if dashboard_host == "0.0.0.0":
dashboard_host = "localhost"
self.url = f"http://{dashboard_host}:{port}/dashboard/index.html"
# handle startup
match self._mode:
case self.Mode.PYWEBVIEW:
self._start_dashboard_viewer(minimized=not open_dashboard_on_launch)
case self.Mode.TRAY_MANAGER:
self._tray_manager_register(open_on_launch=open_dashboard_on_launch, active_project=active_project)
case self.Mode.BROWSER:
if open_dashboard_on_launch:
if not system_has_usable_display():
log.info("Not opening the Serena dashboard because no usable display was detected.")
else:
self.open_dashboard_in_browser()
@staticmethod
def _open_url_in_browser(url: str) -> None:
# Use a subprocess to avoid any output from webbrowser.open being written to stdout
subprocess.Popen(
[sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=False,
)
def open_dashboard_in_browser(self) -> None:
return self._open_url_in_browser(self.url)
@staticmethod
def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None:
"""
Main function of the subprocess for starting the dashboard viewer
"""
try:
SerenaDashboardViewer(url, start_minimized=minimized, parent_process_id=parent_process_id).run()
except webview.errors.WebViewException as e:
log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}")
# Fall back to opening the browser window if the window was supposed to be shown directly
if not minimized:
open_url_in_browser(url)
def _start_dashboard_viewer(self, minimized: bool) -> None:
"""
Starts the dashboard viewer (in a separate process) or, if the current platform does not support it,
opens the dashboard in the default web browser.
:param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform).
If the viewer is not supported on the current platform, then this controls whether to open the browser window.
"""
self._dashboard_viewer_process = multiprocessing.Process(
target=self._start_dashboard_viewer_process_function, args=(self.url, minimized, os.getpid()), daemon=True
)
self._dashboard_viewer_process.start()
def _tray_manager_register(self, open_on_launch: bool, active_project: Project | None) -> None:
"""
Ensure the tray manager is running and register this dashboard instance with it.
If the current platform supports the native tray manager, this method starts
the manager (if not already running) and registers the instance. Otherwise,
it falls back to opening the dashboard in the default web browser.
:param open_on_launch: whether the dashboard should be opened immediately
"""
if not system_has_usable_display():
log.info("Not starting the Serena tray manager because no usable display was detected.")
return
# ensure the singleton tray manager process is running
SerenaDashboardTrayManager.ensure_running()
# determine the current project name (if any)
project_name = active_project.project_name if active_project is not None else None
# register this instance with the tray manager
SerenaDashboardTrayManager.register_instance(
port=self._port,
dashboard_url=self.url,
project=project_name,
started_at=datetime.now().isoformat(timespec="seconds"),
open_viewer=open_on_launch,
)
def shutdown(self) -> None:
"""
Frees resources, terminating the dashboard viewer process (if any) and unregistering from the tray manager (if applicable).
"""
if self._dashboard_viewer_process is not None:
log.info("Stopping the dashboard viewer process ...")
self._dashboard_viewer_process.terminate()
self._dashboard_viewer_process = None
if self._mode == self.Mode.TRAY_MANAGER:
SerenaDashboardTrayManager.unregister_instance(port=self._port)
def update_active_project(self, active_project: Project | None) -> None:
"""
Updates the active project (where applicable).
:param active_project: the currently active project or None if no project is active
"""
if self._mode == self.Mode.TRAY_MANAGER:
project_name = active_project.project_name if active_project is not None else None
SerenaDashboardTrayManager.update_project(port=self._port, project=project_name)
class SerenaAgent:
def __init__(
self,
@@ -269,8 +411,9 @@ class SerenaAgent:
if necessary.
"""
self._active_project: Project | None = None
self._project_activation_callback = project_activation_callback
self._gui_log_viewer: Optional["GuiLogViewer"] = None
self._dashboard_viewer_process: multiprocessing.Process | None = None
self._dashboard_manager: DashboardManager | None = None
self.version = serena_version()
@@ -285,9 +428,6 @@ class SerenaAgent:
self.serena_config.get_registered_project(project, autoregister=True) if project is not None else None
)
# dashboard URL (set when dashboard is started)
self._dashboard_url: str | None = None
# adjust log level
serena_log_level = self.serena_config.log_level
if Logger.root.level != serena_log_level:
@@ -365,7 +505,6 @@ class SerenaAgent:
# Initialize the prompt factory
self.prompt_factory = SerenaPromptFactory()
self._project_activation_callback = project_activation_callback
# activate the given project (if any), also updating the active modes
# Note: We cannot update the active tools yet, because the base toolset has not been computed yet
@@ -390,10 +529,6 @@ class SerenaAgent:
self._active_tools: AvailableTools
self._update_active_tools()
# record the agent start time (used for tray manager registration)
self._agent_started_at = datetime.now().isoformat(timespec="seconds")
self._dashboard_port: int | None = None
# start the dashboard (web frontend), registering its log handler
# should be the last thing to happen in the initialization since the dashboard
# may access various parts of the agent
@@ -401,17 +536,13 @@ class SerenaAgent:
self._dashboard_thread, port = SerenaDashboardAPI(
get_memory_log_handler(), tool_names, agent=self, tool_usage_stats=self._tool_usage_stats
).run_in_thread(host=self.serena_config.web_dashboard_listen_address)
self._dashboard_port = port
dashboard_host = self.serena_config.web_dashboard_listen_address
if dashboard_host == "0.0.0.0":
dashboard_host = "localhost"
dashboard_url = f"http://{dashboard_host}:{port}/dashboard/index.html"
self._dashboard_url = dashboard_url
log.info("Serena web dashboard started at %s", dashboard_url)
self._ensure_tray_manager_and_register(open_on_launch=self.serena_config.web_dashboard_open_on_launch)
self._dashboard_manager = DashboardManager(
port, self.serena_config.web_dashboard_listen_address, self.serena_config.web_dashboard_open_on_launch, self._active_project
)
log.info("Serena web dashboard started at %s", self._dashboard_manager.url)
# inform the GUI window (if any)
if self._gui_log_viewer is not None:
self._gui_log_viewer.set_dashboard_url(dashboard_url)
self._gui_log_viewer.set_dashboard_url(self._dashboard_manager.url)
self._send_usage_info()
@@ -578,82 +709,9 @@ class SerenaAgent:
"""
:return: the URL of the web dashboard, or None if the dashboard is not running
"""
return self._dashboard_url
@staticmethod
def _start_dashboard_viewer_process_function(url: str, minimized: bool, parent_process_id: int) -> None:
"""
Main function of the subprocess for starting the dashboard viewer
"""
try:
SerenaDashboardViewer(url, start_minimized=minimized, parent_process_id=parent_process_id).run()
except webview.errors.WebViewException as e:
log.warning(f"Could not open Serena Dashboard viewer. Cause:\n{e}")
# Fall back to opening the browser window if the window was supposed to be shown directly
if not minimized:
SerenaAgent._open_dashboard_in_browser(url)
def _start_dashboard_viewer(self, minimized: bool) -> None:
"""
Starts the dashboard viewer (in a separate process) or, if the current platform does not support it,
opens the dashboard in the default web browser.
:param minimized: whether the dashboard viewer should be started minimized (if supported on the current platform).
If the viewer is not supported on the current platform, then this controls whether to open the browser window.
"""
if not system_has_usable_display():
log.info("Not starting the Serena dashboard viewer because no usable display was detected.")
return
url = self.get_dashboard_url()
assert url is not None
if SerenaDashboardViewer.is_current_platform_supported():
self._dashboard_viewer_process = multiprocessing.Process(
target=self._start_dashboard_viewer_process_function, args=(url, minimized, os.getpid()), daemon=True
)
self._dashboard_viewer_process.start()
else:
log.info("Not starting Serena dashboard viewer because the current platform does not support it; using browser-based fallback")
if not minimized:
self._open_dashboard_in_browser(url)
def _ensure_tray_manager_and_register(self, open_on_launch: bool) -> None:
"""
Ensure the tray manager is running and register this dashboard instance with it.
If the current platform supports the native tray manager, this method starts
the manager (if not already running) and registers the instance. Otherwise,
it falls back to opening the dashboard in the default web browser.
:param open_on_launch: whether the dashboard should be opened immediately
"""
if not system_has_usable_display():
log.info("Not starting the Serena tray manager because no usable display was detected.")
return
url = self.get_dashboard_url()
assert url is not None
assert self._dashboard_port is not None
if SerenaDashboardTrayManager.is_current_platform_supported():
# ensure the singleton tray manager process is running
SerenaDashboardTrayManager.ensure_running()
# determine the current project name (if any)
project_name = self._active_project.project_name if self._active_project is not None else None
# register this instance with the tray manager, optionally opening a viewer
SerenaDashboardTrayManager.register_instance(
port=self._dashboard_port,
dashboard_url=url,
project=project_name,
started_at=self._agent_started_at,
open_viewer=open_on_launch,
)
else:
log.info("Not starting Serena tray manager because the current platform does not support it; using browser-based fallback")
if open_on_launch:
self._open_dashboard_in_browser(url)
if self._dashboard_manager is None:
return None
return self._dashboard_manager.url
def open_dashboard(self) -> bool:
"""
@@ -661,27 +719,16 @@ class SerenaAgent:
:return: True if the dashboard was opened, False if it could not be opened
"""
if self._dashboard_url is None:
if self._dashboard_manager is None:
raise Exception("Dashboard is not running.")
if not system_has_usable_display():
log.warning("Not opening the Serena web dashboard because no usable display was detected.")
return False
self._open_dashboard_in_browser(self._dashboard_url)
self._dashboard_manager.open_dashboard_in_browser()
return True
@staticmethod
def _open_dashboard_in_browser(url: str) -> None:
# Use a subprocess to avoid any output from webbrowser.open being written to stdout
subprocess.Popen(
[sys.executable, "-c", f"import webbrowser; webbrowser.open({url!r})"],
stdin=subprocess.DEVNULL,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=False,
)
def get_exposed_tool_instances(self) -> list["Tool"]:
"""
:return: the tool instances which are exposed (e.g. to the MCP client).
@@ -900,10 +947,9 @@ class SerenaAgent:
if self._project_activation_callback is not None:
self._project_activation_callback()
# notify the tray manager of the project change (if the dashboard is running)
dashboard_port = getattr(self, "_dashboard_port", None)
if dashboard_port is not None and SerenaDashboardTrayManager.is_current_platform_supported():
SerenaDashboardTrayManager.update_project(dashboard_port, project.project_name)
# notify the dashboard manager of the project change (if applicable)
if self._dashboard_manager:
self._dashboard_manager.update_active_project(self._active_project)
return True
@@ -1048,13 +1094,9 @@ class SerenaAgent:
log.info("Stopping the GUI log window ...")
self._gui_log_viewer.stop()
self._gui_log_viewer = None
if self._dashboard_viewer_process:
log.info("Stopping the dashboard viewer process ...")
self._dashboard_viewer_process.terminate()
self._dashboard_viewer_process = None
if hasattr(self, "_dashboard_port"):
if self._dashboard_port is not None and SerenaDashboardTrayManager.is_current_platform_supported():
SerenaDashboardTrayManager.unregister_instance(self._dashboard_port)
if self._dashboard_manager:
self._dashboard_manager.shutdown()
self._dashboard_manager = None
def shutdown(self) -> None:
"""