From 2bc82307201d1ac623437d0cd802aa2624d20bf6 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Oct 2025 00:32:00 +0100 Subject: [PATCH 01/11] Report error and stack trace for any uncaught exceptions in task queue --- src/serena/agent.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 796b8c93..2d95225d 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -378,8 +378,12 @@ class SerenaAgent: self._task_executor_task_index += 1 def task_execution_wrapper() -> Any: - with LogTime(task_name, logger=log): - return task() + try: + with LogTime(task_name, logger=log): + return task() + except Exception as e: + log.error(f"Error during execution of {task_name}: {e}", exc_info=e) + raise log.info(f"Scheduling {task_name}") return self._task_executor.submit(task_execution_wrapper) From aeec47d1762e73148f012ddd994e4b60eb9a863e Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Oct 2025 00:48:14 +0100 Subject: [PATCH 02/11] Revert "Allowed add_language to be issued asynchronously" This reverts commit 2c2fcc6a07cd26d6e36e0fddf29061e9beb04020. --- src/serena/agent.py | 11 ++--------- src/serena/dashboard.py | 2 +- src/serena/project.py | 7 ++++--- 3 files changed, 7 insertions(+), 13 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 2d95225d..811994a8 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -544,22 +544,15 @@ class SerenaAgent: ls_specific_settings=self.serena_config.ls_specific_settings, ) - def add_language(self, language: Language, wait_for_ls_startup: bool = False) -> None: + def add_language(self, language: Language) -> None: """ Adds a new language to the active project, spawning the respective language server and updating the project configuration. The addition is scheduled via the agent's task executor and executed synchronously, i.e. the method returns when the addition is complete. :param language: the language to add - :param wait_for_ls_startup: whether to wait for the language server to start up before returning (synchronous execution) - or to just issue the addition task and return immediately (asynchronous execution) """ - task = lambda: self.get_active_project_or_raise().add_language(language) - task_name = f"AddLanguage:{language.value}" - if wait_for_ls_startup: - self.execute_task(task, name=task_name) - else: - self.issue_task(task, name=task_name) + self.execute_task(lambda: self.get_active_project_or_raise().add_language(language), name=f"AddLanguage:{language.value}") def remove_language(self, language: Language) -> None: """ diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 2325af90..be7a7214 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -331,7 +331,7 @@ class SerenaDashboardAPI: raise ValueError(f"Invalid language: {request_add_language.language}") # Add the language to the active project - self._agent.add_language(language, wait_for_ls_startup=False) + self._agent.add_language(language) def _remove_language(self, request_remove_language: RequestRemoveLanguage) -> None: from solidlsp.ls_config import Language diff --git a/src/serena/project.py b/src/serena/project.py index 46a9d5f7..662a87f8 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -405,9 +405,6 @@ class Project(ToStringMixin): if language in self.project_config.languages: log.info(f"Language {language.value} is already present in the project configuration.") return - # update the project configuration - self.project_config.languages.append(language) - self.save_config() # start the language server (if the LS manager is active) if self.language_server_manager is None: @@ -416,6 +413,10 @@ class Project(ToStringMixin): log.info("Adding and starting the language server for new language %s ...", language.value) self.language_server_manager.add_language_server(language) + # update the project configuration + self.project_config.languages.append(language) + self.save_config() + def remove_language(self, language: Language) -> None: """ Removes a programming language from the project configuration, stopping the corresponding From eb4a740369cfcf4556d2a56506e8e82c3a5383ba Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Oct 2025 00:51:24 +0100 Subject: [PATCH 03/11] Improve language addition in dashboard * Place button next to language list * Show spinner and hide button while language is being added --- src/serena/resources/dashboard/dashboard.css | 40 +++++++++++++++ src/serena/resources/dashboard/dashboard.js | 52 +++++++++++--------- src/serena/resources/dashboard/index.html | 1 - 3 files changed, 69 insertions(+), 24 deletions(-) diff --git a/src/serena/resources/dashboard/dashboard.css b/src/serena/resources/dashboard/dashboard.css index d320f24c..b97e02df 100644 --- a/src/serena/resources/dashboard/dashboard.css +++ b/src/serena/resources/dashboard/dashboard.css @@ -711,3 +711,43 @@ body { background: rgba(255, 68, 68, 0.2); transform: scale(1.1); } + +.language-add-btn { + padding: 6px 12px; + font-size: 13px; + font-weight: 500; + border-radius: 6px; + border: 1px dashed var(--border-color); + background: var(--bg-secondary); + color: var(--text-primary); + cursor: pointer; + transition: all 0.2s; +} + +.language-add-btn:hover { + background: var(--bg-tertiary); + border-color: var(--btn-primary); + color: var(--btn-primary); +} + +.language-spinner { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 6px 12px; +} + +.spinner { + width: 16px; + height: 16px; + border: 2px solid var(--border-color); + border-top-color: var(--btn-primary); + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { + transform: rotate(360deg); + } +} diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index fbb306b7..24eb8f93 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -83,7 +83,6 @@ class Dashboard { this.$availableToolsDisplay = $('#available-tools-display'); this.$availableModesDisplay = $('#available-modes-display'); this.$availableContextsDisplay = $('#available-contexts-display'); - this.$addLanguageBtn = $('#add-language-btn'); this.$addLanguageModal = $('#add-language-modal'); this.$modalLanguageSelect = $('#modal-language-select'); this.$modalProjectName = $('#modal-project-name'); @@ -112,7 +111,6 @@ class Dashboard { this.$themeToggle.click(this.toggleTheme.bind(this)); this.$refreshStats.click(this.loadStats.bind(this)); this.$clearStats.click(this.clearStats.bind(this)); - this.$addLanguageBtn.click(this.openLanguageModal.bind(this)); this.$modalAddBtn.click(this.addLanguageFromModal.bind(this)); this.$modalCancelBtn.click(this.closeLanguageModal.bind(this)); this.$modalClose.click(this.closeLanguageModal.bind(this)); @@ -302,6 +300,13 @@ class Dashboard { } html += ''; }); + // Add the "Add Language" button inline with language badges (only if active project exists) + if (config.active_project && config.active_project.name) { + html += ''; + html += ''; + } html += ''; } else { html += 'N/A'; @@ -328,12 +333,6 @@ class Dashboard { html += ''; - // Configuration help link - html += '
'; - html += '📖'; - html += 'View Configuration Guide'; - html += '
'; - // Active tools - collapsible html += '
'; html += '

'; @@ -362,14 +361,16 @@ class Dashboard { html += '

'; } + // Configuration help link + html += '
'; + html += '📖'; + html += 'View Configuration Guide'; + html += '
'; + this.$configDisplay.html(html); - // Show/hide add language button based on jetbrains mode - if (this.jetbrainsMode) { - this.$addLanguageBtn.hide(); - } else { - this.$addLanguageBtn.show(); - } + // Attach event handlers for the dynamically created add language button + $('#add-language-btn').click(this.openLanguageModal.bind(this)); // Attach event handlers for language remove buttons const self = this; @@ -1145,8 +1146,13 @@ class Dashboard { } const self = this; - // Disable button during request - self.$modalAddBtn.prop('disabled', true).text('Adding...'); + + // Close modal immediately + self.closeLanguageModal(); + + // Hide the inline add language button and show spinner + $('#add-language-btn').hide(); + $('#add-language-spinner').show(); $.ajax({ url: '/add_language', @@ -1157,21 +1163,21 @@ class Dashboard { }), success: function(response) { if (response.status === 'success') { - // Close modal - self.closeLanguageModal(); - // Reload config to show updated language + // Reload config to show updated language (this will restore the button) self.loadConfigOverview(); } else { alert('Error: ' + response.message); + // Restore button visibility on error + $('#add-language-btn').show(); + $('#add-language-spinner').hide(); } }, error: function(xhr, status, error) { console.error('Error adding language:', error); alert('Error adding language: ' + (xhr.responseJSON ? xhr.responseJSON.message : error)); - }, - complete: function() { - // Re-enable button - self.$modalAddBtn.prop('disabled', false).text('Add Language'); + // Restore button visibility on error + $('#add-language-btn').show(); + $('#add-language-spinner').hide(); } }); } diff --git a/src/serena/resources/dashboard/index.html b/src/serena/resources/dashboard/index.html index ccbf972d..9f4ecf8c 100644 --- a/src/serena/resources/dashboard/index.html +++ b/src/serena/resources/dashboard/index.html @@ -46,7 +46,6 @@

Current Configuration

-
Loading configuration...
From 6e26d08d5d0e7c433a0b8fb29577b83949c41851 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Oct 2025 01:28:06 +0100 Subject: [PATCH 04/11] Represent the agent's task queue explicitly --- src/serena/agent.py | 46 +++++++++++++++++++++++++++++++++------------ 1 file changed, 34 insertions(+), 12 deletions(-) diff --git a/src/serena/agent.py b/src/serena/agent.py index 811994a8..112d9061 100644 --- a/src/serena/agent.py +++ b/src/serena/agent.py @@ -2,6 +2,7 @@ The Serena Model Context Protocol (MCP) Server """ +import concurrent.futures import multiprocessing import os import platform @@ -9,12 +10,15 @@ import sys import threading import webbrowser from collections.abc import Callable -from concurrent.futures import Future, ThreadPoolExecutor +from concurrent.futures import Future from logging import Logger +from queue import Queue +from threading import Thread from typing import TYPE_CHECKING, Any, Optional, TypeVar from sensai.util import logging from sensai.util.logging import LogTime +from sensai.util.string import ToStringMixin from interprompt.jinja_template import JinjaTemplate from serena import serena_version @@ -153,7 +157,9 @@ class SerenaAgent: # create executor for starting the language server and running tools in another thread # This executor is used to achieve linear task execution, so it is important to use a single-threaded executor. - self._task_executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="SerenaAgentExecutor") + self._task_executor_queue = Queue() + self._task_executor_thread = Thread(target=self._process_task_queue, name="SerenaAgentTaskExecutor", daemon=True) + self._task_executor_thread.start() self._task_executor_lock = threading.Lock() self._task_executor_task_index = 1 @@ -192,6 +198,29 @@ class SerenaAgent: process.start() process.join(timeout=1) + class Task(ToStringMixin): + def __init__(self, function: Callable[[], Any], name: str): + self.name = name + self.future = concurrent.futures.Future() + self._function = function + + def _tostring_includes(self) -> list[str]: + return ["name"] + + def execute(self): + try: + with LogTime(self.name, logger=log): + result = self._function() + self.future.set_result(result) + except Exception as e: + log.error(f"Error during execution of {self.name}: {e}", exc_info=e) + self.future.set_exception(e) + + def _process_task_queue(self): + while True: + task: SerenaAgent.Task = self._task_executor_queue.get() + task.execute() + def get_language_server_manager(self) -> LanguageServerManager | None: if self._active_project is not None: return self._active_project.language_server_manager @@ -376,17 +405,10 @@ class SerenaAgent: with self._task_executor_lock: task_name = f"Task-{self._task_executor_task_index}[{name or task.__name__}]" self._task_executor_task_index += 1 - - def task_execution_wrapper() -> Any: - try: - with LogTime(task_name, logger=log): - return task() - except Exception as e: - log.error(f"Error during execution of {task_name}: {e}", exc_info=e) - raise - log.info(f"Scheduling {task_name}") - return self._task_executor.submit(task_execution_wrapper) + task = SerenaAgent.Task(function=task, name=task_name) + self._task_executor_queue.put(task) + return task.future def execute_task(self, task: Callable[[], T], name: str | None = None) -> T: """ From e85afc1ea8ce7179dfdb9fe9e6a9e578b5de2d2f Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Oct 2025 01:49:02 +0100 Subject: [PATCH 05/11] Apply configuration updates (with defaults and backward compatibility) at the dictionary level, allowing old configurations to be properly updated when resaving (outdated entries removed/new entries added in re-saved version) --- src/serena/config/serena_config.py | 60 ++++++++++++++++++++---------- src/serena/project.py | 4 +- 2 files changed, 43 insertions(+), 21 deletions(-) diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index cbdbf6d4..848d94c8 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -223,21 +223,44 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): def rel_path_to_project_yml(cls) -> str: return os.path.join(SERENA_MANAGED_DIR_NAME, cls.SERENA_DEFAULT_PROJECT_FILE) + @classmethod + def load_commented_map(cls, yml_path: str) -> CommentedMap: + """ + Load the project configuration as a CommentedMap, preserving comments and ensuring + completeness of the configuration by applying default values for missing fields + and backward compatibility adjustments. + + :param yml_path: the path to the project.yml file + :return: a CommentedMap representing a full project configuration + """ + data = load_yaml(yml_path, preserve_comments=True) + + # apply defaults for new fields + data["languages"] = data.get("languages", []) + data["ignored_paths"] = data.get("ignored_paths", []) + data["excluded_tools"] = data.get("excluded_tools", []) + data["included_optional_tools"] = data.get("included_optional_tools", []) + data["read_only"] = data.get("read_only", False) + data["ignore_all_files_in_gitignore"] = data.get("ignore_all_files_in_gitignore", True) + data["initial_prompt"] = data.get("initial_prompt", "") + data["encoding"] = data.get("encoding", DEFAULT_SOURCE_FILE_ENCODING) + + # backward compatibility: handle single "language" field + if len(data["languages"]) == 0 and "language" in data: + data["languages"] = [data["language"]] + if "language" in data: + del data["language"] + + return data + @classmethod def _from_dict(cls, data: dict[str, Any]) -> Self: """ - Create a ProjectConfig instance from a configuration dictionary + Create a ProjectConfig instance from a (full) configuration dictionary """ - project_name = data["project_name"] - language_strings = data.get("languages", []) - - # backwards compatibility lang_name_mapping = {"javascript": "typescript"} - if len(language_strings) == 0 and "language" in data: - language_strings = [data["language"]] - languages: list[Language] = [] - for language_str in language_strings: + for language_str in data["languages"]: try: language_str = language_str.lower() if language_str in lang_name_mapping: @@ -248,15 +271,15 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): raise ValueError(f"Invalid language: {data['language']}.\nValid language_strings are: {[l.value for l in Language]}") from e return cls( - project_name=project_name, + project_name=data["project_name"], languages=languages, - ignored_paths=data.get("ignored_paths", []), - excluded_tools=data.get("excluded_tools", []), - included_optional_tools=data.get("included_optional_tools", []), - read_only=data.get("read_only", False), - ignore_all_files_in_gitignore=data.get("ignore_all_files_in_gitignore", True), - initial_prompt=data.get("initial_prompt", ""), - encoding=data.get("encoding", DEFAULT_SOURCE_FILE_ENCODING), + ignored_paths=data["ignored_paths"], + excluded_tools=data["excluded_tools"], + included_optional_tools=data["included_optional_tools"], + read_only=data["read_only"], + ignore_all_files_in_gitignore=data["ignore_all_files_in_gitignore"], + initial_prompt=data["initial_prompt"], + encoding=data["encoding"], ) def to_yaml_dict(self) -> dict: @@ -279,8 +302,7 @@ class ProjectConfig(ToolInclusionDefinition, ToStringMixin): return cls.autogenerate(project_root) else: raise FileNotFoundError(f"Project configuration file not found: {yaml_path}") - with open(yaml_path, encoding=SERENA_FILE_ENCODING) as f: - yaml_data = yaml.safe_load(f) + yaml_data = cls.load_commented_map(str(yaml_path)) if "project_name" not in yaml_data: yaml_data["project_name"] = project_root.name return cls._from_dict(yaml_data) diff --git a/src/serena/project.py b/src/serena/project.py index 662a87f8..c1227523 100644 --- a/src/serena/project.py +++ b/src/serena/project.py @@ -12,7 +12,7 @@ from serena.constants import SERENA_FILE_ENCODING, SERENA_MANAGED_DIR_NAME from serena.ls_manager import LanguageServerFactory, LanguageServerManager from serena.text_utils import MatchedConsecutiveLines, search_files from serena.util.file_system import GitignoreParser, match_path -from serena.util.general import load_yaml, save_yaml +from serena.util.general import save_yaml from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_utils import FileUtils @@ -116,7 +116,7 @@ class Project(ToStringMixin): """ config_path = os.path.join(self.project_root, self.project_config.rel_path_to_project_yml()) log.info("Saving updated project configuration to %s", config_path) - config_with_comments = load_yaml(config_path, preserve_comments=True) + config_with_comments = ProjectConfig.load_commented_map(config_path) config_with_comments.update(self.project_config.to_yaml_dict()) save_yaml(config_path, config_with_comments, preserve_comments=True) From 148b489293f0ff865f2a445aff35dc4d71c19d11 Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Oct 2025 01:52:14 +0100 Subject: [PATCH 06/11] Serena's project.yml: Add typescript language --- .serena/project.yml | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/.serena/project.yml b/.serena/project.yml index a265810e..edba1eeb 100644 --- a/.serena/project.yml +++ b/.serena/project.yml @@ -1,11 +1,5 @@ -# language of the project (csharp, python, rust, java, typescript, javascript, go, cpp, or ruby) -# Special requirements: -# * csharp: Requires the presence of a .sln file in the project folder. -language: python - -# whether to use the project's gitignore file to ignore files -# Added on 2025-04-07 ignore_all_files_in_gitignore: true + # list of additional paths to ignore # same syntax as gitignore, so you can use * and ** # Was previously called `ignored_dirs`, please update your config if you are using that. @@ -64,3 +58,8 @@ excluded_tools: [] initial_prompt: "" project_name: "serena" +languages: +- python +- typescript +included_optional_tools: [] +encoding: utf-8 From ce0a4081fafd56ffc09de1a57923a636c9bfc57d Mon Sep 17 00:00:00 2001 From: Dominik Jain Date: Thu, 30 Oct 2025 02:05:33 +0100 Subject: [PATCH 07/11] Use full Serena logo in dashboard --- src/serena/resources/dashboard/dashboard.css | 9 +++++---- src/serena/resources/dashboard/dashboard.js | 4 ++-- src/serena/resources/dashboard/index.html | 3 ++- .../dashboard/serena-logo-dark-mode.svg | Bin 0 -> 217940 bytes src/serena/resources/dashboard/serena-logo.svg | Bin 0 -> 217846 bytes 5 files changed, 9 insertions(+), 7 deletions(-) create mode 100644 src/serena/resources/dashboard/serena-logo-dark-mode.svg create mode 100644 src/serena/resources/dashboard/serena-logo.svg diff --git a/src/serena/resources/dashboard/dashboard.css b/src/serena/resources/dashboard/dashboard.css index b97e02df..40ca1960 100644 --- a/src/serena/resources/dashboard/dashboard.css +++ b/src/serena/resources/dashboard/dashboard.css @@ -43,15 +43,16 @@ body { font-family: 'Consolas', 'Monaco', 'Courier New', monospace; margin: 0; - padding: 20px; - padding-top: 130px; background-color: var(--bg-primary); color: var(--text-primary); transition: background-color 0.3s ease, color 0.3s ease; } +.main { + padding: 20px; +} + .header { - position: fixed; top: 0; left: 0; right: 0; @@ -72,7 +73,7 @@ body { } .logo-container img { - max-width: 300px; + max-width: 250px; height: auto; display: block; } diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index 24eb8f93..34384173 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -993,9 +993,9 @@ class Dashboard { const logoElement = document.getElementById('serena-logo'); if (logoElement) { if (theme === 'dark') { - logoElement.src = 'serena-logs-dark-mode.png'; + logoElement.src = 'serena-logo-dark-mode.svg'; } else { - logoElement.src = 'serena-logs.png'; + logoElement.src = 'serena-logo.svg'; } } } diff --git a/src/serena/resources/dashboard/index.html b/src/serena/resources/dashboard/index.html index 9f4ecf8c..1be0c059 100644 --- a/src/serena/resources/dashboard/index.html +++ b/src/serena/resources/dashboard/index.html @@ -38,7 +38,7 @@
- +
@@ -184,6 +184,7 @@
+