From 8f89bc32b1b88b071439520e4a13020a97ebcc04 Mon Sep 17 00:00:00 2001 From: Michael Panchenko Date: Sat, 10 Jan 2026 00:52:31 +0700 Subject: [PATCH] Adding news support in dashboard, WIP --- src/serena/config/serena_config.py | 4 + src/serena/dashboard.py | 35 +++++- src/serena/resources/dashboard/dashboard.css | 90 +++++++++++++++ src/serena/resources/dashboard/dashboard.js | 103 ++++++++++++++++++ src/serena/resources/dashboard/index.html | 7 ++ .../resources/dashboard/news/20260109.html | 14 +++ 6 files changed, 251 insertions(+), 2 deletions(-) create mode 100644 src/serena/resources/dashboard/news/20260109.html diff --git a/src/serena/config/serena_config.py b/src/serena/config/serena_config.py index 7933c214..62fc1b29 100644 --- a/src/serena/config/serena_config.py +++ b/src/serena/config/serena_config.py @@ -80,6 +80,10 @@ class SerenaPaths: If a name of a mode matches a name of a mode in SERENAS_OWN_MODES_YAML_DIR, the user mode will override the default mode definition. """ + self.news_snippet_id_file: str = os.path.join(self.serena_user_home_dir, "last_read_news_snippet_id.txt") + """ + file containing the ID of the last read news snippet + """ def get_next_log_file_path(self, prefix: str) -> str: """ diff --git a/src/serena/dashboard.py b/src/serena/dashboard.py index 55d8b87b..3a88b305 100644 --- a/src/serena/dashboard.py +++ b/src/serena/dashboard.py @@ -2,6 +2,7 @@ import os import socket import threading from collections.abc import Callable +from pathlib import Path from typing import TYPE_CHECKING, Any, Self from flask import Flask, Response, request, send_from_directory @@ -9,8 +10,8 @@ from pydantic import BaseModel from sensai.util import logging from serena.analytics import ToolUsageStats -from serena.config.serena_config import LanguageBackend -from serena.constants import SERENA_DASHBOARD_DIR +from serena.config.serena_config import LanguageBackend, SerenaPaths +from serena.constants import REPO_ROOT, SERENA_DASHBOARD_DIR from serena.task_executor import TaskExecutor from serena.util.logging import MemoryLogHandler @@ -317,6 +318,36 @@ class SerenaDashboardAPI: except Exception as e: return {"status": "error", "message": str(e)} + @self._app.route("/news_snippet_ids", methods=["GET"]) + def get_news_snippet_ids() -> dict[str, str | list[int]]: + def _get_unread_news_ids() -> list[int]: + all_news_files = (Path(REPO_ROOT) / "src" / "serena" / "resources" / "dashboard" / "news").glob("*.html") + all_news_ids = sorted([int(f.stem) for f in all_news_files]) + news_snippet_id_file = SerenaPaths().news_snippet_id_file + if not os.path.exists(news_snippet_id_file): + return all_news_ids + with open(news_snippet_id_file, encoding="utf-8") as f: + last_read_news_id = int(f.read().strip()) + return [news_id for news_id in all_news_ids if news_id > last_read_news_id] + + try: + unread_news_ids = _get_unread_news_ids() + return {"news_snippet_ids": unread_news_ids, "status": "success"} + except Exception as e: + return {"status": "error", "message": str(e)} + + @self._app.route("/mark_news_snippet_as_read", methods=["POST"]) + def mark_news_snippet_as_read() -> dict[str, str]: + try: + request_data = request.get_json() + news_snippet_id = int(request_data.get("news_snippet_id")) + news_snippet_id_file = SerenaPaths().news_snippet_id_file + with open(news_snippet_id_file, "w", encoding="utf-8") as f: + f.write(str(news_snippet_id)) + return {"status": "success", "message": f"Marked news snippet {news_snippet_id} as read"} + except Exception as e: + return {"status": "error", "message": str(e)} + def _get_log_messages(self, request_log: RequestLog) -> ResponseLog: all_messages = self._memory_log_handler.get_log_messages() requested_messages = all_messages[request_log.start_idx :] if request_log.start_idx <= len(all_messages) else [] diff --git a/src/serena/resources/dashboard/dashboard.css b/src/serena/resources/dashboard/dashboard.css index 1bcc7a23..aa1d7eb7 100644 --- a/src/serena/resources/dashboard/dashboard.css +++ b/src/serena/resources/dashboard/dashboard.css @@ -49,6 +49,96 @@ html { --stats-header: #3a3a3a; } +.news-section { + background: var(--bg-secondary); + padding: 20px; + border-radius: var(--border-radius); + box-shadow: var(--shadow); + margin-bottom: 25px; +} + +.news-section h2 { + margin: 0 0 20px 0; + font-size: 18px; + color: var(--text-primary); +} + +.news-item { + padding: 20px; + border: 1px solid var(--border-color); + border-radius: var(--border-radius); + margin-bottom: 15px; + background: var(--bg-primary); + position: relative; +} + +.news-item:last-child { + margin-bottom: 0; +} + +.news-item h3 { + margin: 0 0 10px 0; + font-size: 16px; + color: var(--text-primary); +} + +.news-item .date { + color: var(--text-muted); + font-size: 13px; + margin: 0 0 15px 0; +} + +.news-item p { + margin: 10px 0; + line-height: 1.6; + color: var(--text-secondary); +} + +.news-item ul { + margin: 10px 0; + padding-left: 25px; + color: var(--text-secondary); +} + +.news-item ul li { + margin: 8px 0; + line-height: 1.5; +} + +.news-item strong { + color: var(--text-primary); +} + +.news-mark-read { + position: absolute; + top: 20px; + right: 20px; + display: flex; + align-items: center; + gap: 8px; + cursor: pointer; + font-size: 13px; + color: var(--text-muted); + user-select: none; +} + +.news-mark-read:hover { + color: var(--text-primary); +} + +.news-mark-read input[type="checkbox"] { + cursor: pointer; + width: 18px; + height: 18px; +} + +.news-no-items { + color: var(--text-muted); + font-style: italic; + text-align: center; + padding: 20px; +} + body { font-family: 'Consolas', 'Monaco', 'Courier New', monospace; margin: 0; diff --git a/src/serena/resources/dashboard/dashboard.js b/src/serena/resources/dashboard/dashboard.js index 63a98419..e4f2a2b9 100644 --- a/src/serena/resources/dashboard/dashboard.js +++ b/src/serena/resources/dashboard/dashboard.js @@ -268,6 +268,8 @@ class Dashboard { this.$editSerenaConfigSaveBtn = $('#edit-serena-config-save-btn'); this.$editSerenaConfigCancelBtn = $('#edit-serena-config-cancel-btn'); this.$modalCloseEditSerenaConfig = $('.modal-close-edit-serena-config'); + this.$newsSection = $('#news-section'); + this.$newsDisplay = $('#news-display'); // Chart references this.countChart = null; @@ -401,6 +403,7 @@ class Dashboard { // Initialize the application this.loadToolNames().then(function () { // Start on overview page + self.loadNews(); self.loadConfigOverview(); self.startConfigPolling(); self.startExecutionsPolling(); @@ -454,6 +457,7 @@ class Dashboard { // Start appropriate polling for the page if (page === 'overview') { + this.loadNews(); this.loadConfigOverview(); this.startConfigPolling(); this.startExecutionsPolling(); @@ -1877,6 +1881,105 @@ class Dashboard { }); } + // ===== News Methods ===== + + loadNews() { + let self = this; + console.log('Loading news...'); + $.ajax({ + url: '/news_snippet_ids', + type: 'GET', + success: function(response) { + console.log('News snippet IDs response:', response); + if (response.status === 'success' && response.news_snippet_ids && response.news_snippet_ids.length > 0) { + console.log('Displaying news with IDs:', response.news_snippet_ids); + self.displayNews(response.news_snippet_ids); + } else { + console.log('No unread news, hiding section'); + self.$newsSection.hide(); + } + }, + error: function(xhr, status, error) { + console.error('Error loading news snippet IDs:', error); + self.$newsSection.hide(); + } + }); + } + + displayNews(newsIds) { + let self = this; + console.log('displayNews called with:', newsIds); + // Sort newest first (descending order) + newsIds.sort((a, b) => b - a); + + if (newsIds.length === 0) { + console.log('No news items to display.'); + self.$newsSection.hide(); + return; + } + self.$newsSection.show(); + self.$newsDisplay.empty(); + console.log('Displaying ' + newsIds.length + ' news items.'); + // Load each news snippet HTML + let loadedCount = 0; + newsIds.forEach(function(newsId) { + $.ajax({ + url: '/dashboard/news/' + newsId + '.html', + type: 'GET', + success: function(html) { + // Wrap the HTML in a container with a checkbox + let $newsContainer = $('
').attr('data-news-id', newsId); + let $newsContent = $(html); + + // Add checkbox for marking as read + let $markRead = $('
'); + let $checkbox = $('').attr('id', 'news-read-' + newsId); + let $label = $('