feat(dashboard): nous-blue theme, bulk sessions, schedule picker (#37383)
* feat(dashboard): nous-blue theme, bulk sessions, schedule picker
Batch of related dashboard improvements gathered on
austin/fix/dashboard-changes:
* Nous Blue theme — faithful port of the LENS_5I overlay system onto
the existing DashboardTheme. Lifts the foreground inversion layer to
z-index 200 to fix the long-standing hover / loading visual artifact,
adds an explicit swatchColors slot so the theme picker shows the
post-inversion preview, and migrates the legacy "lens-5i" theme key
from localStorage / API to "nous-blue" on first read.
* Theme-aware series colors: new --series-input-token /
--series-output-token CSS vars consumed by Analytics + Models
charts; ToolCall + ModelInfoCard switched to semantic
--color-success for diff lines and the Tools capability badge.
* Analytics + Models headers: consolidate period selector + refresh
next to the page title and drop the redundant period badge.
* Bulk session management — "Delete empty (N)" button + per-row
checkboxes with shift-click range select and a bulk-delete action
bar. Backed by SessionDB.delete_sessions() /
delete_empty_sessions() plus POST /api/sessions/bulk-delete and
DELETE /api/sessions/empty (registered before the templated
/api/sessions/{session_id} family so they don't get shadowed).
Hard cap of 500 IDs per bulk request. Full pytest coverage.
* Cron page — human-readable schedule picker (every-interval / daily
/ weekly / monthly / once / custom) replaces the raw cron
expression input; the job list now renders "Weekly on Mon, Wed,
Fri at 14:30" instead of "30 14 * * 1,3,5". English-only ordinals
for monthly schedules so non-English locales don't get incorrect
suffixes.
* example-dashboard plugin moved from plugins/ to tests/fixtures/ so
stock installs no longer ship the demo. Tests install it
dynamically via a pytest fixture that also reorders the FastAPI
routes.
* i18n: 40+ new keys for the bulk-select UI and schedule
picker/describer translated across all 16 locales.
Co-authored-by: Cursor <cursoragent@cursor.com>
* refactor(dashboard): dedupe memory provider picker
The memory provider <Select> lived on both /system and /plugins,
writing the same config.yaml field through two different endpoints
with no cross-page refresh. Remove the picker from /system in favor
of a read-only status row + link to /plugins, where it pairs with
the context-engine picker under "Plugin providers".
/system retains the destructive admin controls (file sizes, Reset
MEMORY.md / USER.md / all). The api.setMemoryProvider client and
PUT /api/memory/provider backend endpoint are left in place for
CLI / script callers.
Co-authored-by: Cursor <cursoragent@cursor.com>
* docs(dashboard): address Copilot review on PR #37383
- Backdrop layer-stack comment claimed LENS_5I-style themes override
--component-backdrop-bg-blend-mode to multiply, but our only
LENS_5I-style theme (nous-blue) keeps the default difference.
Reword to describe what the code actually does and present the
var as a forward-looking extension hook.
- /api/sessions/bulk-delete docstring promised the response would
echo back the list of deleted IDs, but the implementation only
returns {ok, deleted}. Tighten the docstring to match the wire
format; the client already knows what it asked to delete, so the
IDs aren't needed.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(dashboard): address copilot review on cron describe + bulk-select checkbox
- schedule.ts: restrict `describeCronExpression` to strictly 5-field cron
expressions. The backend `parse_schedule` also accepts the 6-field
`min hour dom month dow year` form, and humanising those by
destructuring only the first five fields would silently drop the year
(e.g. ``0 9 * * * 2099`` rendered as "Daily at 09:00"). 6+ field
expressions now fall through to the raw-string fallback so the user
sees what's actually scheduled.
- SessionsPage.tsx (SessionRow): wire the bulk-select Checkbox's
``onClick`` directly instead of attaching it to a parent ``<span>``
with a no-op ``onCheckedChange``. Radix forwards onClick to the
underlying ``<button role=checkbox>``, so the same handler now drives
both mouse clicks (preserving shift-key state for range select) and
keyboard activation (Space on the focused checkbox, which the browser
synthesises as a click on the <button>). Improves a11y / keyboard UX
without changing the controlled-selection model.
- SessionsPage.tsx: also extend ``SessionRowProps`` with the new
``onRename`` / ``onExport`` props introduced on main so the row's
destructured prop types resolve after the merge.
Co-authored-by: Cursor <cursoragent@cursor.com>
---------
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -3925,6 +3925,117 @@ def _session_latest_descendant(session_id: str):
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# CRITICAL — every literal-path route below MUST be declared BEFORE the
|
||||
# templated ``/api/sessions/{session_id}`` family that follows. FastAPI/
|
||||
# Starlette match routes in registration order, and the ``{session_id}``
|
||||
# pattern is unconstrained — it would otherwise swallow e.g.
|
||||
# ``DELETE /api/sessions/empty``, ``POST /api/sessions/bulk-delete``, or
|
||||
# ``GET /api/sessions/stats`` as "operate on the session with id
|
||||
# 'empty'" / "'bulk-delete'" / "'stats'", which would 404 (or worse,
|
||||
# succeed and delete the wrong row). Same story as the older
|
||||
# ``/api/sessions/search`` endpoint up at line ~1191. If you split or
|
||||
# reorder this block, move every route in it together.
|
||||
class BulkDeleteSessions(BaseModel):
|
||||
ids: List[str]
|
||||
|
||||
|
||||
@app.post("/api/sessions/bulk-delete")
|
||||
async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
|
||||
"""Delete every session in ``body.ids`` in a single DB transaction.
|
||||
|
||||
Backs the dashboard's bulk-select-and-delete flow on the sessions
|
||||
page. POST (not DELETE) because most HTTP clients refuse to send a
|
||||
request body on DELETE and a body is the natural shape for a list
|
||||
of IDs — Starlette accepts both, but POSTing a list keeps proxies,
|
||||
curl, and the browser ``fetch`` API consistent.
|
||||
|
||||
Per-row contract matches :meth:`SessionDB.delete_sessions`:
|
||||
|
||||
* Unknown IDs are silently skipped (the response ``deleted`` count
|
||||
reflects what really happened, not the input length). This is
|
||||
deliberate — UI selection state can race against another tab's
|
||||
delete, and we'd rather succeed-on-the-rest than fail-the-whole-
|
||||
batch.
|
||||
* Children of every deleted parent are orphaned, not cascade-
|
||||
deleted.
|
||||
* Active and archived sessions ARE deleted when explicitly
|
||||
selected — unlike ``DELETE /api/sessions/empty``, the user
|
||||
hand-picked the rows so we trust the selection.
|
||||
* Like the other session-delete endpoints, this does NOT pass a
|
||||
``sessions_dir`` through; on-disk transcript / request-dump
|
||||
cleanup runs at the CLI/agent layer on the next prune pass.
|
||||
|
||||
The response carries the actual deleted count, so the dashboard
|
||||
can surface it in a toast. The IDs that were removed are not
|
||||
echoed back because the client already knows what it asked to
|
||||
delete (unknown IDs are silently skipped — see contract above)
|
||||
and can prune its in-memory list directly from the request.
|
||||
"""
|
||||
# Enforce a hard cap so a runaway/typo'd selection can't lock the
|
||||
# DB writer for an extended window. The dashboard pages 20 rows
|
||||
# at a time; 500 covers a "select all on every page in a
|
||||
# reasonable scrollback" worst case without opening the door to
|
||||
# multi-thousand-row transactions.
|
||||
if len(body.ids) > 500:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="ids must contain at most 500 entries",
|
||||
)
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
deleted = db.delete_sessions(body.ids)
|
||||
return {"ok": True, "deleted": deleted}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/sessions/empty/count")
|
||||
async def count_empty_sessions_endpoint():
|
||||
"""Return the number of empty, ended, non-archived sessions.
|
||||
|
||||
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
|
||||
UI hides the affordance so users aren't presented with a button
|
||||
that does nothing. Cheap, single-COUNT query.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
return {"count": db.count_empty_sessions()}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.delete("/api/sessions/empty")
|
||||
async def delete_empty_sessions_endpoint():
|
||||
"""Delete every empty (``message_count == 0``), ended,
|
||||
non-archived session in a single transaction.
|
||||
|
||||
Safety contract mirrors :meth:`SessionDB.delete_empty_sessions`:
|
||||
|
||||
* Active sessions are skipped (``ended_at IS NULL``) so a live
|
||||
agent isn't yanked mid-handshake.
|
||||
* Archived sessions are skipped — the user explicitly chose to
|
||||
keep those rows.
|
||||
* Children of deleted parents are orphaned, not cascade-deleted.
|
||||
|
||||
Like the single-session ``DELETE /api/sessions/{id}`` endpoint
|
||||
below, this doesn't pass a ``sessions_dir`` through — the on-disk
|
||||
transcript / request-dump cleanup is wired at the CLI/agent layer
|
||||
but the web server historically leaves file cleanup to the next
|
||||
prune-on-startup pass. Matching that pre-existing trade-off keeps
|
||||
the two delete endpoints' DB-vs-disk behaviour consistent.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
try:
|
||||
deleted = db.delete_empty_sessions()
|
||||
return {"ok": True, "deleted": deleted}
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/sessions/stats")
|
||||
async def get_session_stats():
|
||||
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
|
||||
@@ -3957,6 +4068,7 @@ async def get_session_stats():
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}")
|
||||
async def get_session_detail(session_id: str):
|
||||
from hermes_state import SessionDB
|
||||
@@ -6745,6 +6857,7 @@ def mount_spa(application: FastAPI):
|
||||
_BUILTIN_DASHBOARD_THEMES = [
|
||||
{"name": "default", "label": "Hermes Teal", "description": "Classic dark teal — the canonical Hermes look"},
|
||||
{"name": "default-large", "label": "Hermes Teal (Large)", "description": "Hermes Teal with bigger fonts and roomier spacing"},
|
||||
{"name": "nous-blue", "label": "Nous Blue", "description": "Light mode — vivid Nous-blue accents on cream canvas"},
|
||||
{"name": "midnight", "label": "Midnight", "description": "Deep blue-violet with cool accents"},
|
||||
{"name": "ember", "label": "Ember", "description": "Warm crimson and bronze — forge vibes"},
|
||||
{"name": "mono", "label": "Mono", "description": "Clean grayscale — minimal and focused"},
|
||||
|
||||
Reference in New Issue
Block a user