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:
Austin Pickett
2026-06-02 12:37:40 -04:00
committed by GitHub
parent a6b6afdff4
commit 6d14a24b79
41 changed files with 3299 additions and 213 deletions

View File

@ -49,8 +49,8 @@ hermes-agent/
│ ├── hermes-achievements/ # Gamified achievement tracking
│ ├── observability/ # Metrics / traces / logs plugin
│ ├── image_gen/ # Image-generation providers
│ └── <others>/ # disk-cleanup, example-dashboard, google_meet, platforms,
│ # spotify, strike-freedom-cockpit, ...
│ └── <others>/ # disk-cleanup, google_meet, platforms, spotify,
│ # strike-freedom-cockpit, ...
├── optional-skills/ # Heavier/niche skills shipped but NOT active by default
├── skills/ # Built-in skills bundled with the repo
├── ui-tui/ # Ink (React) terminal UI — `hermes --tui`

View File

@ -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"},

View File

@ -3186,6 +3186,178 @@ class SessionDB:
self._remove_session_files(sessions_dir, session_id)
return deleted
def delete_sessions(
self,
session_ids: List[str],
sessions_dir: Optional[Path] = None,
) -> int:
"""Delete every session in *session_ids* in a single transaction.
Backs the dashboard's bulk-select-then-delete flow on the
sessions page (``POST /api/sessions/bulk-delete``). Mirrors the
single-session :meth:`delete_session` contract per row:
* Unknown IDs are silently skipped (no 404) — selection state
in the UI can race against another tab's delete, and we'd
rather succeed-on-the-rest than fail-the-whole-batch.
* Children of every deleted ID are orphaned
(``parent_session_id → NULL``), never cascade-deleted, so a
branch / subagent transcript survives an inadvertent parent
delete.
* Messages and the session row both go in one
``_execute_write`` call so a partial failure can't leave the
DB in a "messages gone but session row still there" state.
* On-disk transcript / ``request_dump_*`` files are cleaned up
outside the DB transaction when *sessions_dir* is provided,
matching :meth:`prune_sessions` and
:meth:`delete_empty_sessions`.
Returns the count of sessions that actually existed and were
deleted (may be less than ``len(session_ids)`` if some IDs were
already gone).
"""
if not session_ids:
return 0
# Dedup + drop any non-string entries up-front. Avoids
# double-counting in the WHERE-IN list and protects against
# callers that pass a list with stray ``None`` values.
unique_ids = list({sid for sid in session_ids if isinstance(sid, str) and sid})
if not unique_ids:
return 0
removed_ids: list[str] = []
def _do(conn):
placeholders = ",".join("?" * len(unique_ids))
# First, filter to IDs that actually exist — we want to
# return the real deleted count, not the input length.
cursor = conn.execute(
f"SELECT id FROM sessions WHERE id IN ({placeholders})",
unique_ids,
)
existing = [row["id"] for row in cursor.fetchall()]
if not existing:
return 0
existing_placeholders = ",".join("?" * len(existing))
# Orphan children whose parent is in the kill list so the
# FK constraint stays satisfied. Pin children whose parent
# is itself in the kill list rather than NULL-ing parents
# of survivors — the IN list on ``parent_session_id`` does
# exactly this.
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({existing_placeholders})",
existing,
)
conn.execute(
f"DELETE FROM messages WHERE session_id IN ({existing_placeholders})",
existing,
)
conn.execute(
f"DELETE FROM sessions WHERE id IN ({existing_placeholders})",
existing,
)
removed_ids.extend(existing)
return len(existing)
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
def count_empty_sessions(self) -> int:
"""Return the count of empty, non-active, non-archived sessions.
"Empty" = ``message_count = 0`` AND the session has ended
(``ended_at IS NOT NULL``) AND is not archived. The ``ended_at``
guard matches the safety contract used by :meth:`prune_sessions`:
only ended sessions are candidates for bulk deletion, so a freshly
spawned session whose first message hasn't landed yet — or one
held open by the live agent — is never sniped out from under
the runtime.
Backs the ``GET /api/sessions/empty/count`` endpoint that lets the
web dashboard hide its "Delete empty" button when there's nothing
to clean up, and pre-populate the confirm dialog with the actual
count.
"""
with self._lock:
cursor = self._conn.execute(
"SELECT COUNT(*) FROM sessions "
"WHERE message_count = 0 "
"AND ended_at IS NOT NULL "
"AND archived = 0"
)
return cursor.fetchone()[0]
def delete_empty_sessions(
self,
sessions_dir: Optional[Path] = None,
) -> int:
"""Delete every empty, ended, non-archived session.
Mirrors :meth:`prune_sessions`' transactional shape:
* Selects candidate IDs first (``message_count = 0`` AND
``ended_at IS NOT NULL`` AND ``archived = 0``) so we never
touch a live session or one the user deliberately archived.
* Orphans any child whose parent is in the kill list — children
of an empty parent are kept and re-parented to ``NULL`` rather
than cascade-deleted, matching ``delete_session`` /
``prune_sessions`` semantics so branch/subagent transcripts
survive an inadvertent parent cleanup.
* Deletes the rows in a single ``_execute_write`` callback so
the operation is atomic — a partial failure (e.g. SIGKILL
mid-loop) doesn't leave the DB in a "messages-deleted but
session-row-still-there" half-state.
* Cleans up on-disk transcript files (``.json`` / ``.jsonl`` /
``request_dump_*``) outside the DB transaction when
``sessions_dir`` is provided. Empty sessions don't typically
have transcript files, but the gateway can leave a stub
``request_dump_*`` if it crashed before the first reply —
so we still sweep, matching ``prune_sessions``.
Returns the number of sessions deleted.
"""
removed_ids: list[str] = []
def _do(conn):
cursor = conn.execute(
"SELECT id FROM sessions "
"WHERE message_count = 0 "
"AND ended_at IS NOT NULL "
"AND archived = 0"
)
session_ids = {row["id"] for row in cursor.fetchall()}
if not session_ids:
return 0
placeholders = ",".join("?" * len(session_ids))
conn.execute(
f"UPDATE sessions SET parent_session_id = NULL "
f"WHERE parent_session_id IN ({placeholders})",
list(session_ids),
)
for sid in session_ids:
# DELETE FROM messages is paranoia — by construction
# these rows have ``message_count = 0`` — but if a
# bookkeeping bug ever lets the counter drift below the
# real row count, we still leave a clean FK state.
conn.execute(
"DELETE FROM messages WHERE session_id = ?", (sid,)
)
conn.execute("DELETE FROM sessions WHERE id = ?", (sid,))
removed_ids.append(sid)
return len(session_ids)
count = self._execute_write(_do)
for sid in removed_ids:
self._remove_session_files(sessions_dir, sid)
return count
def prune_sessions(
self,
older_than_days: int = 90,

View File

@ -1,17 +0,0 @@
"""Example dashboard plugin — backend API routes.
Mounted at /api/plugins/example/ by the dashboard plugin system.
This minimal plugin exists so the test suite has a stable, side-effect-free
GET endpoint to verify that plugin API routes work with auth.
"""
from fastapi import APIRouter
router = APIRouter()
@router.get("/hello")
async def hello():
"""Simple greeting endpoint to demonstrate plugin API routes."""
return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"}

View File

@ -1,7 +1,7 @@
{
"name": "example",
"label": "Example",
"description": "Example dashboard plugin — used by test suite for auth coverage",
"description": "Test-only dashboard plugin fixture — installed by tests that need a stable plugin API endpoint to verify auth + static-asset behaviour",
"icon": "Sparkles",
"version": "1.0.0",
"tab": {

View File

@ -0,0 +1,24 @@
"""Example dashboard plugin — backend API routes (test fixture).
This plugin lives under ``tests/fixtures/plugins/`` so it is NOT shipped as
part of the bundled-plugins set; a stock hermes-agent install does not see
an "Example" tab in its sidebar. The ``_install_example_plugin`` pytest
fixture in ``tests/hermes_cli/test_web_server.py`` copies this directory
into ``$HERMES_HOME/plugins/example-dashboard/`` and forces the dashboard
plugin discovery cache to rescan, so tests that need a stable, side-effect-
free GET endpoint to verify plugin API auth + static-asset behaviour can
hit ``/api/plugins/example/hello`` (and ``/dashboard-plugins/example/
manifest.json``) without depending on any production-facing plugin.
Mounted at /api/plugins/example/ by the dashboard plugin system.
"""
from fastapi import APIRouter
router = APIRouter()
@router.get("/hello")
async def hello():
"""Simple greeting endpoint to demonstrate plugin API routes."""
return {"message": "Hello from the example plugin!", "plugin": "example", "version": "1.0.0"}

View File

@ -2,6 +2,7 @@
import os
import json
import shutil
from pathlib import Path
from unittest.mock import patch, MagicMock
@ -14,6 +15,97 @@ from hermes_cli.config import (
)
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
# Path to the test-only example-dashboard plugin. Lives under
# tests/fixtures/ so the bundled-plugins directory stays clean — stock
# installs no longer ship a dummy "Example" sidebar tab. Tests that
# depend on its routes opt in via the `_install_example_plugin` fixture
# below.
_EXAMPLE_PLUGIN_FIXTURE = (
Path(__file__).resolve().parent.parent / "fixtures" / "plugins" / "example-dashboard"
)
@pytest.fixture
def _install_example_plugin(_isolate_hermes_home):
"""Drop the example-dashboard fixture into the per-test HERMES_HOME
user-plugins directory and force the web_server's dashboard plugin
cache + API mount to rediscover it.
The plugin used to live under ``<repo>/plugins/example-dashboard/``
and was loaded for every install, putting an "Example" tab in every
user's sidebar. It is now a tests-only fixture: any test that needs
``/api/plugins/example/hello`` or ``/dashboard-plugins/example/...``
requests this fixture so the plugin appears only for that test's
isolated ``HERMES_HOME``.
The user-plugin source is preferred over a transient
``HERMES_BUNDLED_PLUGINS`` override because the bundled dir is
resolved per-call (other tests in the suite implicitly rely on the
real bundled plugins — kanban, hermes-achievements, model providers
— being available, and globally swapping that root would yank them
all). User plugins are first in the discovery search order, so
laying down the fixture here is enough.
"""
from hermes_constants import get_hermes_home
from hermes_cli import web_server
user_plugins_dir = get_hermes_home() / "plugins"
user_plugins_dir.mkdir(parents=True, exist_ok=True)
dst = user_plugins_dir / "example-dashboard"
if dst.exists():
shutil.rmtree(dst)
shutil.copytree(_EXAMPLE_PLUGIN_FIXTURE, dst)
# Snapshot the existing routes BEFORE mounting so we can:
# 1. Identify the routes the mount call appends.
# 2. Restore the original list on teardown — otherwise leftover
# ``/api/plugins/example/*`` routes leak into subsequent tests
# and start serving requests against a torn-down HERMES_HOME.
app = web_server.app
original_routes = list(app.router.routes)
# Bust the module-level cache and re-discover so the example plugin
# shows up in `_get_dashboard_plugins()`. `_mount_plugin_api_routes`
# imports the plugin's `plugin_api.py` and ``include_router``s its
# FastAPI router under ``/api/plugins/example/*``. The static-asset
# route at ``/dashboard-plugins/<name>/<path>`` reads the plugins
# list dynamically per request, so the rescan alone is enough for
# the static-asset tests; the API auth tests additionally need the
# route reorder below.
web_server._dashboard_plugins_cache = None
web_server._get_dashboard_plugins(force_rescan=True)
web_server._mount_plugin_api_routes()
# ``include_router`` appends the new routes to the END of
# ``app.router.routes``. That works fine at import time — the SPA
# catch-all ``mount_spa(app)`` registers AFTER the initial mount
# call — but when we mount mid-flight the catch-all is already in
# place, so the new ``/api/plugins/example/*`` route loses the
# match-order race and we get a 404. Move the newly-appended routes
# to the front of the list so FastAPI matches them first. They're
# path-prefixed to ``/api/plugins/example/`` and can't shadow
# anything else.
new_routes = [r for r in app.router.routes if r not in original_routes]
for route in new_routes:
app.router.routes.remove(route)
for offset, route in enumerate(new_routes):
app.router.routes.insert(offset, route)
try:
yield
finally:
# Restore the original route list — drops the example plugin's
# routes so the next test sees a clean app — and clear the
# cache for the same reason.
app.router.routes[:] = original_routes
web_server._dashboard_plugins_cache = None
# ---------------------------------------------------------------------------
# reload_env tests
# ---------------------------------------------------------------------------
@ -2551,12 +2643,284 @@ class TestNormaliseThemeExtensions:
assert r["componentStyles"]["card"] == {"opacity": "0.8", "zIndex": "5"}
class TestBulkDeleteSessionsEndpoint:
"""Tests for ``POST /api/sessions/bulk-delete`` — backs the
dashboard's "Delete N selected" flow on the sessions page.
Locks in four things:
1. Route-ordering: ``/api/sessions/bulk-delete`` must shadow the
templated ``/api/sessions/{session_id}`` route below it (see
the block comment in ``hermes_cli/web_server.py``).
2. Behaviour parity with :meth:`SessionDB.delete_sessions` — real
deleted count, archive/active sessions deleted on explicit
selection.
3. The 500-ID payload cap is enforced.
4. Auth gating (issue #19533 contract).
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
monkeypatch.setattr(
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
)
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _seed(self, ids):
from hermes_state import SessionDB
db = SessionDB()
try:
for sid in ids:
db.create_session(session_id=sid, source="cli")
finally:
db.close()
def test_requires_auth(self):
resp = self.client.post("/api/sessions/bulk-delete", json={"ids": ["x"]})
assert resp.status_code == 401
def test_deletes_listed_sessions_only(self):
from hermes_state import SessionDB
self._seed(["a", "b", "c"])
resp = self.auth_client.post(
"/api/sessions/bulk-delete", json={"ids": ["a", "b"]}
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 2}
db = SessionDB()
try:
assert db.get_session("a") is None
assert db.get_session("b") is None
assert db.get_session("c") is not None
finally:
db.close()
def test_unknown_ids_silently_skipped(self):
"""The endpoint never 404s on a missing ID — it returns the
real deleted count so a UI selection that raced against
another tab still resolves cleanly."""
self._seed(["real"])
resp = self.auth_client.post(
"/api/sessions/bulk-delete",
json={"ids": ["real", "ghost1", "ghost2"]},
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 1}
def test_empty_list_is_noop(self):
"""``ids: []`` returns ``deleted: 0`` (200, not 400) — the UI
treats an empty selection as a no-op rather than an error."""
resp = self.auth_client.post(
"/api/sessions/bulk-delete", json={"ids": []}
)
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 0}
def test_payload_cap_enforced(self):
"""501 IDs returns 400 — a hard cap stops a runaway selection
from holding the SQLite writer for an extended window."""
resp = self.auth_client.post(
"/api/sessions/bulk-delete",
json={"ids": [f"s{i}" for i in range(501)]},
)
assert resp.status_code == 400
# 500 exactly still succeeds (no rows actually present, so
# deleted=0 — but it's not the cap path).
resp = self.auth_client.post(
"/api/sessions/bulk-delete",
json={"ids": [f"s{i}" for i in range(500)]},
)
assert resp.status_code == 200
def test_route_order_not_shadowed_by_session_id(self):
"""Pin the route-ordering contract: ``POST /api/sessions/bulk-delete``
must hit the bulk handler, not be re-interpreted via the
templated ``/api/sessions/{session_id}`` family. Concretely the
response carries our ``ok`` + ``deleted`` keys."""
resp = self.auth_client.post(
"/api/sessions/bulk-delete", json={"ids": []}
)
assert resp.status_code == 200
body = resp.json()
assert body.get("ok") is True
assert "deleted" in body, (
"If this assertion fails, /api/sessions/bulk-delete is "
"being shadowed by /api/sessions/{session_id} — check "
"registration order in hermes_cli/web_server.py."
)
class TestDeleteEmptySessionsEndpoint:
"""Tests for ``GET /api/sessions/empty/count`` and
``DELETE /api/sessions/empty`` — the bulk-delete endpoints backing
the dashboard's "Delete empty" button.
Locks in three things the implementation has to get right:
1. Route-ordering: the literal ``/api/sessions/empty[/count]`` paths
must shadow the templated ``/api/sessions/{session_id}`` route
above them. A regression here would route ``DELETE /api/sessions/
empty`` to the single-session handler with ``session_id="empty"``
(which 404s instead of bulk-deleting).
2. Behaviour parity with :meth:`SessionDB.delete_empty_sessions`:
active sessions and archived sessions are both preserved.
3. Auth gating: both routes require the session token like every
other ``/api/*`` endpoint (issue #19533 contract).
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
try:
from starlette.testclient import TestClient
except ImportError:
pytest.skip("fastapi/starlette not installed")
import hermes_state
from hermes_constants import get_hermes_home
from hermes_cli.web_server import app, _SESSION_HEADER_NAME, _SESSION_TOKEN
# Pin the SessionDB to the isolated HERMES_HOME so each test
# starts with a clean state.db.
monkeypatch.setattr(
hermes_state, "DEFAULT_DB_PATH", get_hermes_home() / "state.db"
)
self.client = TestClient(app)
self.auth_client = TestClient(app)
self.auth_client.headers[_SESSION_HEADER_NAME] = _SESSION_TOKEN
def _seed(self):
"""Build the standard test corpus:
* ``empty1`` / ``empty2`` — ended, no messages → should delete
* ``hasmsg`` — ended, has one message → must survive
* ``live`` — un-ended, empty → must survive (active)
* ``archived``— ended, empty, archived → must survive
"""
from hermes_state import SessionDB
db = SessionDB()
try:
db.create_session(session_id="empty1", source="cli")
db.end_session("empty1", end_reason="done")
db.create_session(session_id="empty2", source="cli")
db.end_session("empty2", end_reason="done")
db.create_session(session_id="hasmsg", source="cli")
db.append_message("hasmsg", role="user", content="hello")
db.end_session("hasmsg", end_reason="done")
db.create_session(session_id="live", source="cli")
db.create_session(session_id="archived", source="cli")
db.end_session("archived", end_reason="done")
db.set_session_archived("archived", True)
finally:
db.close()
def test_count_endpoint_requires_auth(self):
"""GET /api/sessions/empty/count must 401 without the session token."""
resp = self.client.get("/api/sessions/empty/count")
assert resp.status_code == 401
def test_delete_endpoint_requires_auth(self):
"""DELETE /api/sessions/empty must 401 without the session token.
Regression guard for issue #19533 — the bulk-delete is a strictly
destructive primitive, the middleware must gate it even if a
future refactor introduces a non-auth path."""
resp = self.client.delete("/api/sessions/empty")
assert resp.status_code == 401
def test_count_returns_only_empty_ended_unarchived(self):
"""With the standard corpus, the count is exactly 2 — only
``empty1`` and ``empty2`` qualify (``hasmsg`` has a message,
``live`` is active, ``archived`` is archived)."""
self._seed()
resp = self.auth_client.get("/api/sessions/empty/count")
assert resp.status_code == 200
assert resp.json() == {"count": 2}
def test_delete_returns_count_and_removes_only_empties(self):
"""DELETE returns the deleted count and removes only the
empty-ended-unarchived rows — same shape contract as the
DB-level method's unit tests."""
from hermes_state import SessionDB
self._seed()
resp = self.auth_client.delete("/api/sessions/empty")
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 2}
db = SessionDB()
try:
assert db.get_session("empty1") is None
assert db.get_session("empty2") is None
# Survivors: hasmsg has a message, live is active, archived
# is archived. All three must still be there.
assert db.get_session("hasmsg") is not None
assert db.get_session("live") is not None
assert db.get_session("archived") is not None
# And the count endpoint now reports 0.
assert db.count_empty_sessions() == 0
finally:
db.close()
def test_delete_with_no_empties_returns_zero(self):
"""No empty sessions → endpoint returns ``deleted: 0`` (200,
not 404). The dashboard relies on this no-op path to surface
a "Nothing to clean up" toast instead of an error."""
resp = self.auth_client.delete("/api/sessions/empty")
assert resp.status_code == 200
assert resp.json() == {"ok": True, "deleted": 0}
def test_route_order_empty_not_shadowed_by_session_id(self):
"""Pin the route-ordering contract: ``DELETE /api/sessions/empty``
must hit the bulk handler, not the templated single-session
handler (which would 404 because no session has id 'empty').
Concretely: a request against the bulk path on an EMPTY corpus
returns ``{ok: True, deleted: 0}``. If the templated route were
winning, we'd see 404 ("Session not found") instead.
"""
resp = self.auth_client.delete("/api/sessions/empty")
assert resp.status_code == 200
body = resp.json()
assert "deleted" in body, (
"If this assertion fails, the literal /api/sessions/empty "
"route is being shadowed by the templated /api/sessions/"
"{session_id} route — check registration order in "
"hermes_cli/web_server.py."
)
class TestPluginAPIAuth:
"""Tests that plugin API routes require the session token (issue #19533)."""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
"""Create a TestClient without the session token header."""
def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
"""Create a TestClient without the session token header.
Pulls in ``_install_example_plugin`` so ``test_plugin_route_allows_auth``
has the ``/api/plugins/example/hello`` endpoint available — the
example plugin is no longer a bundled plugin, so the fixture
installs it into the per-test ``HERMES_HOME``.
"""
try:
from starlette.testclient import TestClient
except ImportError:
@ -2581,10 +2945,12 @@ class TestPluginAPIAuth:
def test_plugin_route_allows_auth(self):
"""Plugin API routes should work with a valid session token.
Use ``/api/plugins/example/hello`` from the example-dashboard plugin —
a stable, side-effect-free GET that's always loaded in tests. With a
valid token the handler should run (200); without one the middleware
should 401 before the handler is reached.
Uses ``/api/plugins/example/hello`` from the example-dashboard
test fixture (installed into HERMES_HOME by the class-level
``_install_example_plugin`` fixture) — a stable, side-effect-free
GET that's only loaded for tests. With a valid token the handler
should run (200); without one the middleware should 401 before
the handler is reached.
"""
# Without auth: middleware blocks before reaching the handler.
resp = self.client.get("/api/plugins/example/hello")
@ -3136,7 +3502,16 @@ class TestDashboardPluginStaticAssetAllowlist:
"""
@pytest.fixture(autouse=True)
def _setup_test_client(self, monkeypatch, _isolate_hermes_home):
def _setup_test_client(self, monkeypatch, _isolate_hermes_home, _install_example_plugin):
"""Create a TestClient and install the example-dashboard fixture.
The static-asset allowlist tests need a plugin to point at —
they verify that ``/dashboard-plugins/example/manifest.json``
is served while ``plugin_api.py`` and ``__pycache__/*.pyc``
from the same directory are not. Since the example plugin is
no longer bundled, ``_install_example_plugin`` lays it down in
the per-test ``HERMES_HOME`` user-plugins dir.
"""
try:
from starlette.testclient import TestClient
except ImportError:

View File

@ -1523,6 +1523,243 @@ class TestDeleteSessionOrphansChildren:
assert grandchild["parent_session_id"] == "child"
class TestBulkDeleteSessions:
"""``delete_sessions(ids)`` — the bulk-delete primitive backing the
sessions-page "Delete N selected" button. Per-row contract matches
:meth:`SessionDB.delete_session` (children orphaned, not cascade-
deleted), but applied across the whole list in one transaction.
Invariants this class locks in:
1. Returns the real deleted count (existing intersection), not
just ``len(session_ids)`` — selection state in the UI can race
against another tab's delete.
2. Unknown IDs are silently skipped, never raise.
3. ``message_count > 0`` sessions are deleted too — unlike
``delete_empty_sessions``, the user explicitly picked them, so
we trust the selection.
4. Live (un-ended) and archived sessions ARE deleted on explicit
selection (no bulk-sweep safety guards apply when the user
hand-picks the row).
5. Children of any deleted parent are orphaned, even when the
parent is mid-list.
6. ``[]`` / ``None``-laden lists are safe no-ops.
"""
def test_deletes_listed_sessions(self, db):
db.create_session(session_id="a", source="cli")
db.append_message("a", role="user", content="hi")
db.create_session(session_id="b", source="cli")
db.create_session(session_id="c", source="cli")
deleted = db.delete_sessions(["a", "b"])
assert deleted == 2
assert db.get_session("a") is None
assert db.get_session("b") is None
# Unlisted survives.
assert db.get_session("c") is not None
def test_returns_real_count_skipping_unknown_ids(self, db):
"""Unknown IDs are silently skipped — the return value reflects
what was *actually* deleted, so the UI can show an accurate
toast even if the selection raced against another tab."""
db.create_session(session_id="real", source="cli")
deleted = db.delete_sessions(["real", "ghost1", "ghost2"])
assert deleted == 1
assert db.get_session("real") is None
def test_empty_list_is_noop(self, db):
"""``[]`` returns 0 without touching the DB. Guards against a
bulk endpoint with an empty payload triggering an
unconditional 'wipe everything' if the caller forgets the
WHERE clause."""
db.create_session(session_id="keep", source="cli")
assert db.delete_sessions([]) == 0
assert db.get_session("keep") is not None
def test_drops_non_string_entries(self, db):
"""Stray ``None`` / empty strings in the input list are
filtered out before hitting SQL. Callers may pull selection IDs
from a Set-like that occasionally contains noise; we don't want
a SQL parameter-type error to fail the whole batch."""
db.create_session(session_id="real", source="cli")
# noinspection PyTypeChecker
deleted = db.delete_sessions(["real", None, "", "ghost"]) # type: ignore[list-item]
assert deleted == 1
assert db.get_session("real") is None
def test_dedupes_duplicate_ids(self, db):
"""The same ID listed twice counts as one deletion. Defends
against a hand-crafted POST body or a UI bug that double-adds
the same selection."""
db.create_session(session_id="real", source="cli")
deleted = db.delete_sessions(["real", "real"])
assert deleted == 1
def test_orphans_children_of_deleted_parents(self, db):
"""Bulk-deleting a parent leaves its children alive but
re-parented to NULL. Same contract as the single-session
:meth:`delete_session` path."""
db.create_session(session_id="parent", source="cli")
db.create_session(
session_id="child", source="cli", parent_session_id="parent"
)
deleted = db.delete_sessions(["parent"])
assert deleted == 1
child = db.get_session("child")
assert child is not None
assert child["parent_session_id"] is None
def test_deletes_archived_and_active_when_selected(self, db):
"""Unlike the safety-gated ``delete_empty_sessions`` sweep,
explicit bulk-select trusts the user — archived sessions and
un-ended live sessions are both deleted when in the list.
Otherwise the selection UI would silently 'leak' rows the user
thought they'd removed."""
db.create_session(session_id="archived", source="cli")
db.end_session("archived", end_reason="done")
db.set_session_archived("archived", True)
db.create_session(session_id="live", source="cli")
deleted = db.delete_sessions(["archived", "live"])
assert deleted == 2
assert db.get_session("archived") is None
assert db.get_session("live") is None
def test_cleans_up_transcript_files(self, db, tmp_path):
"""When ``sessions_dir`` is provided, on-disk transcripts are
swept as part of the bulk operation — mirrors the per-row
:meth:`delete_session(sessions_dir=...)` behaviour so the
bulk-delete CLI / web flows don't leak files."""
db.create_session(session_id="s1", source="cli")
db.create_session(session_id="s2", source="cli")
(tmp_path / "s1.jsonl").write_text("")
(tmp_path / "s2.json").write_text("{}")
deleted = db.delete_sessions(["s1", "s2"], sessions_dir=tmp_path)
assert deleted == 2
assert not (tmp_path / "s1.jsonl").exists()
assert not (tmp_path / "s2.json").exists()
class TestDeleteEmptySessions:
"""``delete_empty_sessions`` sweeps every ended, non-archived session
whose ``message_count`` is 0. Backs the dashboard's "Delete empty"
button — see ``SessionsPage.tsx`` + ``DELETE /api/sessions/empty``
in ``hermes_cli/web_server.py``.
Invariants this class locks in:
1. Only ``message_count = 0`` rows are touched.
2. Active (un-ended) sessions are skipped even if they're empty —
the agent might be mid-handshake, and yanking the row would
race the live runtime.
3. Archived sessions are skipped — the user already filed them away.
4. Children of a deleted parent are orphaned (parent_session_id →
NULL) rather than cascade-deleted, matching the
``delete_session`` / ``prune_sessions`` contract.
5. The pre-DB count matches the post-DB delete return value.
"""
def test_count_and_delete_empties_only(self, db):
# Two empty + ended sessions → both should be in the kill list.
db.create_session(session_id="empty1", source="cli")
db.end_session("empty1", end_reason="done")
db.create_session(session_id="empty2", source="cli")
db.end_session("empty2", end_reason="done")
# One non-empty + ended session → must survive.
db.create_session(session_id="hasmsg", source="cli")
db.append_message("hasmsg", role="user", content="Hello")
db.end_session("hasmsg", end_reason="done")
assert db.count_empty_sessions() == 2
deleted = db.delete_empty_sessions()
assert deleted == 2
assert db.get_session("empty1") is None
assert db.get_session("empty2") is None
assert db.get_session("hasmsg") is not None
assert db.count_empty_sessions() == 0
def test_skips_active_empty_sessions(self, db):
"""A live (un-ended) empty session is what you get during the
race between session-create and the first message landing. The
sweep must not delete it — that would yank a session out from
under the agent before its first reply persists."""
db.create_session(session_id="live", source="cli")
# Deliberately no end_session() — session is "active".
assert db.count_empty_sessions() == 0
assert db.delete_empty_sessions() == 0
assert db.get_session("live") is not None
def test_skips_archived_empty_sessions(self, db):
"""Archived = soft-hidden by the user. They explicitly chose to
keep the row around (even though it's empty), so the bulk sweep
must not surprise them by deleting it. Restoring an archived
session is one click; resurrecting one we deleted is impossible."""
db.create_session(session_id="archived_empty", source="cli")
db.end_session("archived_empty", end_reason="done")
db.set_session_archived("archived_empty", True)
assert db.count_empty_sessions() == 0
assert db.delete_empty_sessions() == 0
assert db.get_session("archived_empty") is not None
def test_returns_zero_when_nothing_to_delete(self, db):
"""No-op path: no candidate rows → return 0, no error."""
db.create_session(session_id="hasmsg", source="cli")
db.append_message("hasmsg", role="user", content="Hello")
db.end_session("hasmsg", end_reason="done")
assert db.count_empty_sessions() == 0
assert db.delete_empty_sessions() == 0
assert db.get_session("hasmsg") is not None
def test_orphans_children_of_deleted_empty_parent(self, db):
"""Even an empty parent can have a child (e.g. a branch session
spawned before the parent received any messages). The sweep
must orphan that child, not cascade-delete it — same contract
as ``delete_session`` and ``prune_sessions``."""
db.create_session(session_id="empty_parent", source="cli")
db.end_session("empty_parent", end_reason="done")
db.create_session(
session_id="child", source="cli", parent_session_id="empty_parent"
)
db.append_message("child", role="user", content="something")
db.end_session("child", end_reason="done")
deleted = db.delete_empty_sessions()
assert deleted == 1
assert db.get_session("empty_parent") is None
child = db.get_session("child")
assert child is not None
assert child["parent_session_id"] is None
def test_cleans_up_on_disk_transcript_files(self, db, tmp_path):
"""When ``sessions_dir`` is provided, transcript files left
behind by a crashed gateway (``request_dump_*.json``) are swept
too. Empty sessions rarely have ``{id}.json`` / ``.jsonl``
transcripts, but the request-dump path is real — the gateway
writes one before the first reply lands, so a crash mid-reply
produces an empty session with a non-empty dump file."""
db.create_session(session_id="empty_with_dump", source="cli")
db.end_session("empty_with_dump", end_reason="done")
dump = tmp_path / "request_dump_empty_with_dump_0.json"
dump.write_text("{}")
transcript = tmp_path / "empty_with_dump.jsonl"
transcript.write_text("")
deleted = db.delete_empty_sessions(sessions_dir=tmp_path)
assert deleted == 1
assert not dump.exists()
assert not transcript.exists()
# =========================================================================
# Schema and WAL mode
# =========================================================================

View File

@ -11,11 +11,27 @@ import fillerBgUrl from "@nous-research/ui/assets/filler-bg0.webp";
* and the warm vignette both read theme-switchable CSS custom properties so
* `ThemeProvider` can repaint the stack without remounting.
*
* z-1 bg = `var(--background-base)`, mix-blend-mode: difference
* z-1 bg = `var(--background-base)`, mix-blend-mode driven by
* `--component-backdrop-bg-blend-mode` (default `difference`).
* Both LENS_0-style dark themes and the LENS_5I-style Nous Blue
* light theme keep `difference` here — the canvas is flipped by
* the z-200 FG inversion layer, not by changing this blend mode.
* The CSS var is exposed as a hook so future presets can override
* it (e.g. `multiply` to paint the bg as-is before inversion)
* without touching this component.
* z-2 bundled filler-bg WebP, inverted, opacity 0.033, difference
* z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten
* z-101 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
* color-dodge) — gated on GPU tier
* z-200 FG inversion = `var(--foreground)` (opaque white in LENS_5I,
* alpha-0 in LENS_0), mix-blend-mode: difference. This is the
* layer that flips the dashboard into "light mode" for inverted
* themes; for normal dark themes its alpha is 0 so it's a no-op.
* Deliberately placed above every UI overlay z-index (modals,
* tooltips, and dropUp dropdowns all sit at z-[100]) so portaled
* elements get inverted along with the rest of the page instead
* of painting with pre-inversion colors on top of the lens.
* z-201 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
* color-dodge) — gated on GPU tier. Sits above the inversion
* layer by design so the grain is not flipped.
*
* `useGpuTier` returns 0 when WebGL is unavailable, the renderer is a
* software rasterizer (SwiftShader/llvmpipe), or the user has
@ -31,10 +47,13 @@ export function Backdrop() {
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[1]"
style={{
backgroundColor: "var(--background-base)",
mixBlendMode: "difference",
}}
style={
{
backgroundColor: "var(--background-base)",
mixBlendMode:
"var(--component-backdrop-bg-blend-mode, difference)",
} as unknown as React.CSSProperties
}
/>
<div
@ -75,10 +94,35 @@ export function Backdrop() {
}}
/>
{/* Foreground inversion layer. Source-of-truth: LENS_5I.Lens.fgOpacity
+ fgBlend: 'difference' in `design-language/src/ui/components/
overlays/lens.ts`. With `--foreground-alpha: 0` (LENS_0 dark default)
the layer is fully transparent and contributes nothing; with
alpha 1 + opaque white it inverts the entire stack below it,
producing the LENS_5I "light mode" look without altering any
downstream component code.
z-200 (not 100) so it sits above every portaled UI overlay —
sidebar tooltips, dropUp dropdowns, and modal dialogs all use
z-[100], which is what the DS Lens picks too; portals append
at the end of <body>, so equal z-index + later DOM order means
they'd paint on top of the inversion and skip the flip. Inlined
z-index for the same reason the DS does it — Tailwind's JIT
scan sometimes drops non-default z utilities. */}
<div
aria-hidden
className="pointer-events-none fixed inset-0"
style={{
backgroundColor: "var(--foreground)",
mixBlendMode: "difference",
zIndex: 200,
}}
/>
{gpuTier > 0 && (
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[101]"
className="pointer-events-none fixed inset-0 z-[201]"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E\")",

View File

@ -86,7 +86,7 @@ export function ModelInfoCard({
{hasCaps && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{caps.supports_tools && (
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<span className="inline-flex items-center gap-1 bg-success/10 px-2 py-0.5 text-xs font-medium text-success">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}

View File

@ -0,0 +1,273 @@
import { useCallback } from "react";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Button } from "@nous-research/ui/ui/components/button";
import { useI18n } from "@/i18n";
import {
buildScheduleString,
DEFAULT_SCHEDULE_STATE,
type IntervalUnit,
type ScheduleBuilderState,
type ScheduleMode,
type Weekday,
WEEKDAY_INDEXES,
} from "@/lib/schedule";
/**
* Human-readable schedule picker for cron job create/edit flows.
*
* Replaces the raw "type a cron expression" input that lived inline in
* ``CronPage``. The picker still emits a single backend-compatible
* schedule string (see ``cron/jobs.py::parse_schedule``), but the user
* fills out shape-appropriate inputs (time picker, weekday toggles,
* datetime-local field) per mode.
*
* Architecture:
*
* - The component is fully controlled. Parent owns the
* ``ScheduleBuilderState`` and the derived schedule string (built
* via ``buildScheduleString`` in render).
* - Mode-specific state slots (``timeOfDay``, ``weekdays``, ...) are
* preserved across mode switches so flipping back to a previous mode
* doesn't erase the user's work.
* - The "Custom" mode is an escape hatch — surfacing it as a normal
* option (instead of hiding it behind an "advanced" toggle) keeps
* power-user workflows discoverable without making everyone scroll
* past it.
*/
export function ScheduleBuilder({ onChange, value }: ScheduleBuilderProps) {
const { t } = useI18n();
const cronStrings = t.cron;
const modeStrings = cronStrings.scheduleModes;
const update = useCallback(
(patch: Partial<ScheduleBuilderState>) => {
onChange({ ...value, ...patch });
},
[onChange, value],
);
const toggleWeekday = useCallback(
(day: Weekday) => {
const present = value.weekdays.includes(day);
update({
weekdays: present
? value.weekdays.filter((d) => d !== day)
: [...value.weekdays, day],
});
},
[update, value.weekdays],
);
return (
<div className="grid gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-schedule-mode">
{cronStrings.scheduleMode ?? "Schedule"}
</Label>
<Select
id="cron-schedule-mode"
value={value.mode}
onValueChange={(v) => update({ mode: v as ScheduleMode })}
>
<SelectOption value="interval">{modeStrings.interval}</SelectOption>
<SelectOption value="daily">{modeStrings.daily}</SelectOption>
<SelectOption value="weekly">{modeStrings.weekly}</SelectOption>
<SelectOption value="monthly">{modeStrings.monthly}</SelectOption>
<SelectOption value="once">{modeStrings.once}</SelectOption>
<SelectOption value="custom">{modeStrings.custom}</SelectOption>
</Select>
</div>
{value.mode === "interval" && (
<div className="grid grid-cols-[1fr_1.4fr] gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-interval-value">
{modeStrings.intervalEvery}
</Label>
<Input
id="cron-interval-value"
type="number"
min={1}
max={9999}
value={String(value.intervalValue)}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
update({
intervalValue: Number.isFinite(n) && n > 0 ? n : 1,
});
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-interval-unit">{modeStrings.intervalUnit}</Label>
<Select
id="cron-interval-unit"
value={value.intervalUnit}
onValueChange={(v) => update({ intervalUnit: v as IntervalUnit })}
>
<SelectOption value="minutes">
{modeStrings.unitMinutes}
</SelectOption>
<SelectOption value="hours">{modeStrings.unitHours}</SelectOption>
<SelectOption value="days">{modeStrings.unitDays}</SelectOption>
</Select>
</div>
</div>
)}
{value.mode === "daily" && (
<TimeOfDayField
id="cron-daily-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
)}
{value.mode === "weekly" && (
<>
<div className="grid gap-2">
<Label>{modeStrings.weekdays}</Label>
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={modeStrings.weekdays}
>
{WEEKDAY_INDEXES.map((d) => {
const isOn = value.weekdays.includes(d);
return (
<Button
key={d}
type="button"
size="sm"
outlined={!isOn}
aria-pressed={isOn}
onClick={() => toggleWeekday(d)}
className="min-w-[2.5rem] font-mono-ui text-xs uppercase"
>
{modeStrings.weekdaysShort[d]}
</Button>
);
})}
</div>
</div>
<TimeOfDayField
id="cron-weekly-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
</>
)}
{value.mode === "monthly" && (
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-month-day">{modeStrings.dayOfMonth}</Label>
<Input
id="cron-month-day"
type="number"
min={1}
max={31}
value={String(value.dayOfMonth)}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
update({
dayOfMonth:
Number.isFinite(n) && n >= 1 && n <= 31 ? n : 1,
});
}}
/>
</div>
<TimeOfDayField
id="cron-monthly-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
</div>
)}
{value.mode === "once" && (
<div className="grid gap-2">
<Label htmlFor="cron-once-at">{modeStrings.onceAt}</Label>
{/* Native datetime-local — emits the exact "YYYY-MM-DDTHH:MM"
shape ``parse_schedule`` accepts on the backend. */}
<input
id="cron-once-at"
type="datetime-local"
className="flex h-9 w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
value={value.onceAt}
onChange={(e) => update({ onceAt: e.target.value })}
/>
</div>
)}
{value.mode === "custom" && (
<div className="grid gap-2">
<Label htmlFor="cron-custom-expr">{modeStrings.customLabel}</Label>
<Input
id="cron-custom-expr"
placeholder={modeStrings.customPlaceholder}
value={value.custom}
onChange={(e) => update({ custom: e.target.value })}
className="font-mono-ui"
/>
<p className="text-xs text-muted-foreground">
{modeStrings.customHint}
</p>
</div>
)}
{/* Inline preview of what we'll send to the backend. Helps users
eyeball the result before hitting Create, and keeps the
schedule grammar discoverable for the custom mode. */}
<p className="text-xs text-muted-foreground">
<span className="opacity-70">{modeStrings.preview}: </span>
<span className="font-mono-ui text-foreground">
{buildScheduleString(value) || modeStrings.previewEmpty}
</span>
</p>
</div>
);
}
function TimeOfDayField({
id,
label,
onChange,
value,
}: TimeOfDayFieldProps) {
return (
<div className="grid gap-2">
<Label htmlFor={id}>{label}</Label>
{/* Native time picker is the right tool for "HH:MM" — saves us
two separate hour/minute selects, respects user locale's
AM/PM preference, and round-trips with ``buildScheduleString``
without parsing. */}
<input
id={id}
type="time"
className="flex h-9 w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
export { DEFAULT_SCHEDULE_STATE };
interface ScheduleBuilderProps {
onChange: (state: ScheduleBuilderState) => void;
value: ScheduleBuilderState;
}
interface TimeOfDayFieldProps {
id: string;
label: string;
onChange: (value: string) => void;
value: string;
}

View File

@ -208,15 +208,25 @@ function ThemeSwitcherOptions({
}
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
const { background, midground, warmGlow } = theme.palette;
// Inverted themes (Nous Blue / future lens themes) author their palette
// pre-inversion — `#FFAC02` reads as `#0053FD` blue once the foreground-
// difference layer flips the page. The picker can't replay that math
// cheaply, so themes opt-in to an explicit `swatchColors` triplet that
// mirrors the on-screen result. Falls back to the raw palette hexes for
// every other theme so existing dark-theme swatches are untouched.
const [c1, c2, c3] = theme.swatchColors ?? [
theme.palette.background.hex,
theme.palette.midground.hex,
theme.palette.warmGlow,
];
return (
<div
aria-hidden
className="flex h-4 w-9 shrink-0 overflow-hidden border border-current/20"
>
<span className="flex-1" style={{ background: background.hex }} />
<span className="flex-1" style={{ background: midground.hex }} />
<span className="flex-1" style={{ background: warmGlow }} />
<span className="flex-1" style={{ background: c1 }} />
<span className="flex-1" style={{ background: c2 }} />
<span className="flex-1" style={{ background: c3 }} />
</div>
);
}

View File

@ -220,7 +220,7 @@ function colorizeDiff(diff: string): React.ReactNode {
function diffLineClass(line: string): string {
if (line.startsWith("+") && !line.startsWith("+++"))
return "text-emerald-500 dark:text-emerald-400";
return "text-success";
if (line.startsWith("-") && !line.startsWith("---"))
return "text-destructive";
if (line.startsWith("@@")) return "text-primary";

View File

@ -141,6 +141,22 @@ export const af: Translations = {
"Dit verwyder die gesprek en al sy boodskappe permanent. Dit kan nie ongedaan gemaak word nie.",
sessionDeleted: "Sessie geskrap",
failedToDelete: "Kon nie sessie skrap nie",
deleteEmpty: "Skrap leë",
deleteEmptyConfirmTitle: "Skrap leë sessies?",
deleteEmptyConfirmMessage:
"Dit verwyder permanent {count} sessies wat geen boodskappe het nie. Aktiewe en geargiveerde sessies word oorgeslaan. Dit kan nie ongedaan gemaak word nie.",
emptySessionsDeleted: "{count} leë sessies geskrap",
failedToDeleteEmpty: "Kon nie leë sessies skrap nie",
selectSession: "Kies sessie",
selectAllOnPage: "Kies alles op hierdie bladsy",
clearSelection: "Maak keuse skoon",
selectedCount: "{count} gekies",
deleteSelected: "Skrap {count}",
deleteSelectedConfirmTitle: "Skrap {count} sessies?",
deleteSelectedConfirmMessage:
"Dit verwyder {count} gekose sessies en al hul boodskappe permanent. Dit kan nie ongedaan gemaak word nie.",
selectedSessionsDeleted: "{count} sessies geskrap",
failedToDeleteSelected: "Kon nie gekose sessies skrap nie",
resumeInChat: "Hervat in Klets",
previousPage: "Vorige bladsy",
nextPage: "Volgende bladsy",
@ -211,6 +227,41 @@ export const af: Translations = {
promptPlaceholder: "Wat moet die agent met elke uitvoering doen?",
schedule: "Skedule (cron-uitdrukking)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Skedule",
scheduleModes: {
interval: "Herhalende interval",
daily: "Daagliks",
weekly: "Weekliks",
monthly: "Maandeliks",
once: "Een keer",
custom: "Pasgemaak (cron-uitdrukking)",
intervalEvery: "Elke",
intervalUnit: "Eenheid",
unitMinutes: "minute",
unitHours: "ure",
unitDays: "dae",
timeOfDay: "Tyd van die dag",
weekdays: "Dae van die week",
weekdaysShort: ["Son", "Maa", "Din", "Woe", "Don", "Vry", "Sat"],
dayOfMonth: "Dag van die maand",
onceAt: "Hardloop op",
customLabel: "Cron-uitdrukking",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-uitdrukking met vyf velde (minuut, uur, dag, maand, weekdag).",
preview: "Word gestuur as",
previewEmpty: "(onvolledig)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Elke {n} min",
everyHours: "Elke {n} u",
everyDays: "Elke {n} d",
dailyAt: "Daagliks om {time}",
weeklyAt: "Weekliks op {days} om {time}",
monthlyAt: "Maandeliks op die {day} om {time}",
onceAt: "Een keer op {time}",
},
deliverTo: "Lewer aan",
scheduledJobs: "Geskeduleerde Take",
noJobs: "Geen cron-take gekonfigureer nie. Skep een hierbo.",

View File

@ -141,6 +141,22 @@ export const de: Translations = {
"Dies entfernt die Unterhaltung und alle Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.",
sessionDeleted: "Sitzung gelöscht",
failedToDelete: "Sitzung konnte nicht gelöscht werden",
deleteEmpty: "Leere löschen",
deleteEmptyConfirmTitle: "Leere Sitzungen löschen?",
deleteEmptyConfirmMessage:
"Dies entfernt dauerhaft {count} Sitzungen ohne Nachrichten. Aktive und archivierte Sitzungen werden übersprungen. Dies kann nicht rückgängig gemacht werden.",
emptySessionsDeleted: "{count} leere Sitzungen gelöscht",
failedToDeleteEmpty: "Leere Sitzungen konnten nicht gelöscht werden",
selectSession: "Sitzung auswählen",
selectAllOnPage: "Alle auf dieser Seite auswählen",
clearSelection: "Auswahl aufheben",
selectedCount: "{count} ausgewählt",
deleteSelected: "{count} löschen",
deleteSelectedConfirmTitle: "{count} Sitzungen löschen?",
deleteSelectedConfirmMessage:
"Dies entfernt {count} ausgewählte Sitzungen und alle zugehörigen Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.",
selectedSessionsDeleted: "{count} Sitzungen gelöscht",
failedToDeleteSelected: "Ausgewählte Sitzungen konnten nicht gelöscht werden",
resumeInChat: "Im Chat fortsetzen",
previousPage: "Vorherige Seite",
nextPage: "Nächste Seite",
@ -211,6 +227,41 @@ export const de: Translations = {
promptPlaceholder: "Was soll der Agent bei jedem Lauf tun?",
schedule: "Zeitplan (Cron-Ausdruck)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Zeitplan",
scheduleModes: {
interval: "Wiederkehrendes Intervall",
daily: "Täglich",
weekly: "Wöchentlich",
monthly: "Monatlich",
once: "Einmalig",
custom: "Benutzerdefiniert (Cron-Ausdruck)",
intervalEvery: "Alle",
intervalUnit: "Einheit",
unitMinutes: "Minuten",
unitHours: "Stunden",
unitDays: "Tage",
timeOfDay: "Uhrzeit",
weekdays: "Wochentage",
weekdaysShort: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
dayOfMonth: "Tag des Monats",
onceAt: "Ausführen am",
customLabel: "Cron-Ausdruck",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-Ausdruck mit fünf Feldern (Minute, Stunde, Tag, Monat, Wochentag).",
preview: "Wird gesendet als",
previewEmpty: "(unvollständig)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Alle {n} Min.",
everyHours: "Alle {n} Std.",
everyDays: "Alle {n} Tage",
dailyAt: "Täglich um {time}",
weeklyAt: "Wöchentlich am {days} um {time}",
monthlyAt: "Monatlich am {day} um {time}",
onceAt: "Einmal am {time}",
},
deliverTo: "Zustellen an",
scheduledJobs: "Geplante Aufgaben",
noJobs: "Keine Cron-Aufgaben konfiguriert. Erstelle oben eine.",

View File

@ -141,6 +141,22 @@ export const en: Translations = {
"This permanently removes the conversation and all of its messages. This cannot be undone.",
sessionDeleted: "Session deleted",
failedToDelete: "Failed to delete session",
deleteEmpty: "Delete empty",
deleteEmptyConfirmTitle: "Delete empty sessions?",
deleteEmptyConfirmMessage:
"This permanently removes {count} sessions that have no messages. Active and archived sessions are skipped. This cannot be undone.",
emptySessionsDeleted: "{count} empty sessions deleted",
failedToDeleteEmpty: "Failed to delete empty sessions",
selectSession: "Select session",
selectAllOnPage: "Select all on this page",
clearSelection: "Clear selection",
selectedCount: "{count} selected",
deleteSelected: "Delete {count}",
deleteSelectedConfirmTitle: "Delete {count} sessions?",
deleteSelectedConfirmMessage:
"This permanently removes {count} selected sessions and all their messages. This cannot be undone.",
selectedSessionsDeleted: "{count} sessions deleted",
failedToDeleteSelected: "Failed to delete selected sessions",
resumeInChat: "Resume in Chat",
previousPage: "Previous page",
nextPage: "Next page",
@ -211,6 +227,41 @@ export const en: Translations = {
promptPlaceholder: "What should the agent do on each run?",
schedule: "Schedule (cron expression)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Schedule",
scheduleModes: {
interval: "Every interval",
daily: "Daily",
weekly: "Weekly",
monthly: "Monthly",
once: "Once",
custom: "Custom (cron expression)",
intervalEvery: "Every",
intervalUnit: "Unit",
unitMinutes: "minutes",
unitHours: "hours",
unitDays: "days",
timeOfDay: "Time of day",
weekdays: "Days of week",
weekdaysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
dayOfMonth: "Day of month",
onceAt: "Run at",
customLabel: "Cron expression",
customPlaceholder: "0 9 * * *",
customHint:
"Five-field cron expression (minute, hour, day, month, weekday).",
preview: "Sends as",
previewEmpty: "(incomplete)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Every {n} min",
everyHours: "Every {n} h",
everyDays: "Every {n} d",
dailyAt: "Daily at {time}",
weeklyAt: "Weekly on {days} at {time}",
monthlyAt: "Monthly on the {day} at {time}",
onceAt: "Once at {time}",
},
deliverTo: "Deliver to",
scheduledJobs: "Scheduled Jobs",
noJobs: "No cron jobs configured. Create one above.",

View File

@ -141,6 +141,22 @@ export const es: Translations = {
"Esto elimina permanentemente la conversación y todos sus mensajes. No se puede deshacer.",
sessionDeleted: "Sesión eliminada",
failedToDelete: "No se pudo eliminar la sesión",
deleteEmpty: "Eliminar vacías",
deleteEmptyConfirmTitle: "¿Eliminar sesiones vacías?",
deleteEmptyConfirmMessage:
"Esto elimina permanentemente {count} sesiones que no tienen mensajes. Se omiten las sesiones activas y archivadas. Esta acción no se puede deshacer.",
emptySessionsDeleted: "{count} sesiones vacías eliminadas",
failedToDeleteEmpty: "No se pudieron eliminar las sesiones vacías",
selectSession: "Seleccionar sesión",
selectAllOnPage: "Seleccionar todas en esta página",
clearSelection: "Limpiar selección",
selectedCount: "{count} seleccionadas",
deleteSelected: "Eliminar {count}",
deleteSelectedConfirmTitle: "¿Eliminar {count} sesiones?",
deleteSelectedConfirmMessage:
"Esto elimina permanentemente {count} sesiones seleccionadas y todos sus mensajes. No se puede deshacer.",
selectedSessionsDeleted: "{count} sesiones eliminadas",
failedToDeleteSelected: "No se pudieron eliminar las sesiones seleccionadas",
resumeInChat: "Reanudar en el chat",
previousPage: "Página anterior",
nextPage: "Página siguiente",
@ -211,6 +227,41 @@ export const es: Translations = {
promptPlaceholder: "¿Qué debe hacer el agente en cada ejecución?",
schedule: "Programación (expresión cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Programación",
scheduleModes: {
interval: "Cada intervalo",
daily: "Diariamente",
weekly: "Semanalmente",
monthly: "Mensualmente",
once: "Una vez",
custom: "Personalizado (expresión cron)",
intervalEvery: "Cada",
intervalUnit: "Unidad",
unitMinutes: "minutos",
unitHours: "horas",
unitDays: "días",
timeOfDay: "Hora del día",
weekdays: "Días de la semana",
weekdaysShort: ["Dom", "Lun", "Mar", "Mié", "Jue", "Vie", "Sáb"],
dayOfMonth: "Día del mes",
onceAt: "Ejecutar el",
customLabel: "Expresión cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expresión cron de cinco campos (minuto, hora, día, mes, día de la semana).",
preview: "Se envía como",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Cada {n} min",
everyHours: "Cada {n} h",
everyDays: "Cada {n} d",
dailyAt: "Diariamente a las {time}",
weeklyAt: "Semanalmente los {days} a las {time}",
monthlyAt: "Mensualmente el {day} a las {time}",
onceAt: "Una vez el {time}",
},
deliverTo: "Entregar a",
scheduledJobs: "Tareas programadas",
noJobs: "No hay tareas cron configuradas. Crea una arriba.",

View File

@ -141,6 +141,22 @@ export const fr: Translations = {
"Cela supprime définitivement la conversation et tous ses messages. Cette action est irréversible.",
sessionDeleted: "Session supprimée",
failedToDelete: "Échec de la suppression de la session",
deleteEmpty: "Supprimer les vides",
deleteEmptyConfirmTitle: "Supprimer les sessions vides ?",
deleteEmptyConfirmMessage:
"Cela supprime définitivement {count} sessions sans messages. Les sessions actives et archivées sont ignorées. Cette action est irréversible.",
emptySessionsDeleted: "{count} sessions vides supprimées",
failedToDeleteEmpty: "Échec de la suppression des sessions vides",
selectSession: "Sélectionner la session",
selectAllOnPage: "Tout sélectionner sur cette page",
clearSelection: "Effacer la sélection",
selectedCount: "{count} sélectionnée(s)",
deleteSelected: "Supprimer {count}",
deleteSelectedConfirmTitle: "Supprimer {count} sessions ?",
deleteSelectedConfirmMessage:
"Cela supprime définitivement {count} sessions sélectionnées et tous leurs messages. Cette action est irréversible.",
selectedSessionsDeleted: "{count} sessions supprimées",
failedToDeleteSelected: "Échec de la suppression des sessions sélectionnées",
resumeInChat: "Reprendre dans le chat",
previousPage: "Page précédente",
nextPage: "Page suivante",
@ -211,6 +227,41 @@ export const fr: Translations = {
promptPlaceholder: "Que doit faire l'agent à chaque exécution ?",
schedule: "Planning (expression cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Planification",
scheduleModes: {
interval: "Intervalle récurrent",
daily: "Quotidien",
weekly: "Hebdomadaire",
monthly: "Mensuel",
once: "Une fois",
custom: "Personnalisé (expression cron)",
intervalEvery: "Toutes les",
intervalUnit: "Unité",
unitMinutes: "minutes",
unitHours: "heures",
unitDays: "jours",
timeOfDay: "Heure de la journée",
weekdays: "Jours de la semaine",
weekdaysShort: ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"],
dayOfMonth: "Jour du mois",
onceAt: "Exécuter le",
customLabel: "Expression cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expression cron à cinq champs (minute, heure, jour, mois, jour de la semaine).",
preview: "Envoyé sous la forme",
previewEmpty: "(incomplet)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Toutes les {n} min",
everyHours: "Toutes les {n} h",
everyDays: "Tous les {n} j",
dailyAt: "Tous les jours à {time}",
weeklyAt: "Chaque {days} à {time}",
monthlyAt: "Le {day} de chaque mois à {time}",
onceAt: "Une fois le {time}",
},
deliverTo: "Livrer à",
scheduledJobs: "Tâches planifiées",
noJobs: "Aucune tâche cron configurée. Créez-en une ci-dessus.",

View File

@ -141,6 +141,22 @@ export const ga: Translations = {
"Baineann sé seo an comhrá agus a chuid teachtaireachtaí ar fad go buan. Ní féidir é seo a chealú.",
sessionDeleted: "Seisiún scriosta",
failedToDelete: "Theip ar scriosadh an tseisiúin",
deleteEmpty: "Scrios folamh",
deleteEmptyConfirmTitle: "Scrios seisiúin fholmha?",
deleteEmptyConfirmMessage:
"Baintear {count} seisiúin gan teachtaireachtaí ar bhealach buan. Ní scriostar seisiúin ghníomhacha agus seisiúin chartlainne. Ní féidir é seo a chealú.",
emptySessionsDeleted: "{count} seisiúin fholmha scriosta",
failedToDeleteEmpty: "Theip ar scriosadh na seisiún folmha",
selectSession: "Roghnaigh seisiún",
selectAllOnPage: "Roghnaigh gach ceann ar an leathanach seo",
clearSelection: "Glan an rogha",
selectedCount: "{count} roghnaithe",
deleteSelected: "Scrios {count}",
deleteSelectedConfirmTitle: "Scrios {count} seisiún?",
deleteSelectedConfirmMessage:
"Bainfear {count} seisiún roghnaithe agus a dteachtaireachtaí go léir go buan. Ní féidir é seo a chur ar ais.",
selectedSessionsDeleted: "Scriosadh {count} seisiún",
failedToDeleteSelected: "Theip ar scriosadh na seisiún roghnaithe",
resumeInChat: "Lean ar aghaidh sa chomhrá",
previousPage: "Leathanach roimhe seo",
nextPage: "An chéad leathanach eile",
@ -211,6 +227,49 @@ export const ga: Translations = {
promptPlaceholder: "Cad ba chóir don agent a dhéanamh ag gach rith?",
schedule: "Sceideal (slonn cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Sceideal",
scheduleModes: {
interval: "Eatramh athfhillteach",
daily: "Go laethúil",
weekly: "Go seachtainiúil",
monthly: "Go míosúil",
once: "Uair amháin",
custom: "Saincheaptha (slonn cron)",
intervalEvery: "Gach",
intervalUnit: "Aonad",
unitMinutes: "nóiméad",
unitHours: "uair",
unitDays: "lá",
timeOfDay: "Am an lae",
weekdays: "Laethanta na seachtaine",
weekdaysShort: [
"Domh",
"Luan",
"Máirt",
"Céad",
"Déar",
"Aoine",
"Sath",
],
dayOfMonth: "Lá den mhí",
onceAt: "Rith ag",
customLabel: "Slonn cron",
customPlaceholder: "0 9 * * *",
customHint:
"Slonn cron cúig réimse (nóiméad, uair, lá, mí, lá den tseachtain).",
preview: "Seoltar mar",
previewEmpty: "(neamhiomlán)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Gach {n} nóim",
everyHours: "Gach {n} u",
everyDays: "Gach {n} lá",
dailyAt: "Go laethúil ag {time}",
weeklyAt: "Gach {days} ag {time}",
monthlyAt: "An {day} de gach mí ag {time}",
onceAt: "Uair amháin ag {time}",
},
deliverTo: "Seachadadh chuig",
scheduledJobs: "Poist sceidealta",
noJobs: "Níl poist cron cumraithe. Cruthaigh ceann thuas.",

View File

@ -141,6 +141,22 @@ export const hu: Translations = {
"Ez véglegesen eltávolítja a beszélgetést és minden üzenetét. A művelet nem vonható vissza.",
sessionDeleted: "Munkamenet törölve",
failedToDelete: "Nem sikerült törölni a munkamenetet",
deleteEmpty: "Üresek törlése",
deleteEmptyConfirmTitle: "Üres munkamenetek törlése?",
deleteEmptyConfirmMessage:
"Ez véglegesen eltávolít {count} olyan munkamenetet, amely nem tartalmaz üzenetet. Az aktív és archivált munkameneteket kihagyja. Ez nem vonható vissza.",
emptySessionsDeleted: "{count} üres munkamenet törölve",
failedToDeleteEmpty: "Nem sikerült törölni az üres munkameneteket",
selectSession: "Munkamenet kijelölése",
selectAllOnPage: "Az oldalon mindegyik kijelölése",
clearSelection: "Kijelölés törlése",
selectedCount: "{count} kijelölve",
deleteSelected: "{count} törlése",
deleteSelectedConfirmTitle: "{count} munkamenet törlése?",
deleteSelectedConfirmMessage:
"Ez véglegesen eltávolítja a kijelölt {count} munkamenetet és minden üzenetüket. A művelet nem vonható vissza.",
selectedSessionsDeleted: "{count} munkamenet törölve",
failedToDeleteSelected: "Nem sikerült törölni a kijelölt munkameneteket",
resumeInChat: "Folytatás a csevegésben",
previousPage: "Előző oldal",
nextPage: "Következő oldal",
@ -211,6 +227,41 @@ export const hu: Translations = {
promptPlaceholder: "Mit tegyen az ügynök minden futtatáskor?",
schedule: "Ütemezés (cron-kifejezés)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Ütemezés",
scheduleModes: {
interval: "Ismétlődő intervallum",
daily: "Naponta",
weekly: "Hetente",
monthly: "Havonta",
once: "Egyszer",
custom: "Egyéni (cron kifejezés)",
intervalEvery: "Minden",
intervalUnit: "Egység",
unitMinutes: "perc",
unitHours: "óra",
unitDays: "nap",
timeOfDay: "Napszak",
weekdays: "Hét napjai",
weekdaysShort: ["V", "H", "K", "Sze", "Cs", "P", "Szo"],
dayOfMonth: "Hónap napja",
onceAt: "Futtatás ekkor",
customLabel: "Cron kifejezés",
customPlaceholder: "0 9 * * *",
customHint:
"Öt mezős cron kifejezés (perc, óra, nap, hónap, hét napja).",
preview: "Elküldve mint",
previewEmpty: "(hiányos)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n} percenként",
everyHours: "{n} óránként",
everyDays: "{n} naponta",
dailyAt: "Naponta {time}-kor",
weeklyAt: "Hetente {days} {time}-kor",
monthlyAt: "Havonta {day} {time}-kor",
onceAt: "Egyszer {time}-kor",
},
deliverTo: "Kézbesítés ide",
scheduledJobs: "Ütemezett feladatok",
noJobs: "Nincs beállított cron-feladat. Hozzon létre egyet fent.",

View File

@ -141,6 +141,22 @@ export const it: Translations = {
"Questa operazione rimuove definitivamente la conversazione e tutti i suoi messaggi. Non può essere annullata.",
sessionDeleted: "Sessione eliminata",
failedToDelete: "Eliminazione della sessione non riuscita",
deleteEmpty: "Elimina vuote",
deleteEmptyConfirmTitle: "Eliminare le sessioni vuote?",
deleteEmptyConfirmMessage:
"Questa azione rimuove in modo permanente {count} sessioni senza messaggi. Le sessioni attive e archiviate vengono ignorate. L'azione non può essere annullata.",
emptySessionsDeleted: "{count} sessioni vuote eliminate",
failedToDeleteEmpty: "Impossibile eliminare le sessioni vuote",
selectSession: "Seleziona sessione",
selectAllOnPage: "Seleziona tutte in questa pagina",
clearSelection: "Annulla selezione",
selectedCount: "{count} selezionate",
deleteSelected: "Elimina {count}",
deleteSelectedConfirmTitle: "Eliminare {count} sessioni?",
deleteSelectedConfirmMessage:
"Verranno eliminate definitivamente {count} sessioni selezionate e tutti i loro messaggi. L'operazione non può essere annullata.",
selectedSessionsDeleted: "{count} sessioni eliminate",
failedToDeleteSelected: "Impossibile eliminare le sessioni selezionate",
resumeInChat: "Riprendi nella chat",
previousPage: "Pagina precedente",
nextPage: "Pagina successiva",
@ -211,6 +227,41 @@ export const it: Translations = {
promptPlaceholder: "Cosa deve fare l'agente a ogni esecuzione?",
schedule: "Pianificazione (espressione cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Pianificazione",
scheduleModes: {
interval: "Intervallo ricorrente",
daily: "Giornaliero",
weekly: "Settimanale",
monthly: "Mensile",
once: "Una volta",
custom: "Personalizzato (espressione cron)",
intervalEvery: "Ogni",
intervalUnit: "Unità",
unitMinutes: "minuti",
unitHours: "ore",
unitDays: "giorni",
timeOfDay: "Ora del giorno",
weekdays: "Giorni della settimana",
weekdaysShort: ["Dom", "Lun", "Mar", "Mer", "Gio", "Ven", "Sab"],
dayOfMonth: "Giorno del mese",
onceAt: "Esegui il",
customLabel: "Espressione cron",
customPlaceholder: "0 9 * * *",
customHint:
"Espressione cron a cinque campi (minuto, ora, giorno, mese, giorno della settimana).",
preview: "Inviato come",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Ogni {n} min",
everyHours: "Ogni {n} h",
everyDays: "Ogni {n} g",
dailyAt: "Tutti i giorni alle {time}",
weeklyAt: "Ogni {days} alle {time}",
monthlyAt: "Il {day} di ogni mese alle {time}",
onceAt: "Una volta il {time}",
},
deliverTo: "Consegna a",
scheduledJobs: "Attività pianificate",
noJobs: "Nessuna attività cron configurata. Creane una sopra.",

View File

@ -141,6 +141,22 @@ export const ja: Translations = {
"会話とそのすべてのメッセージが完全に削除されます。この操作は取り消せません。",
sessionDeleted: "セッションを削除しました",
failedToDelete: "セッションの削除に失敗しました",
deleteEmpty: "空を削除",
deleteEmptyConfirmTitle: "空のセッションを削除しますか?",
deleteEmptyConfirmMessage:
"メッセージのない {count} 件のセッションを完全に削除します。アクティブおよびアーカイブされたセッションはスキップされます。この操作は元に戻せません。",
emptySessionsDeleted: "{count} 件の空のセッションを削除しました",
failedToDeleteEmpty: "空のセッションの削除に失敗しました",
selectSession: "セッションを選択",
selectAllOnPage: "このページの全てを選択",
clearSelection: "選択を解除",
selectedCount: "{count}件選択中",
deleteSelected: "{count}件削除",
deleteSelectedConfirmTitle: "{count}件のセッションを削除しますか?",
deleteSelectedConfirmMessage:
"選択した{count}件のセッションとそのすべてのメッセージが完全に削除されます。この操作は取り消せません。",
selectedSessionsDeleted: "{count}件のセッションを削除しました",
failedToDeleteSelected: "選択したセッションの削除に失敗しました",
resumeInChat: "チャットで再開",
previousPage: "前のページ",
nextPage: "次のページ",
@ -211,6 +227,40 @@ export const ja: Translations = {
promptPlaceholder: "実行ごとにエージェントが行う内容は?",
schedule: "スケジュール (cron 式)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "スケジュール",
scheduleModes: {
interval: "繰り返し間隔",
daily: "毎日",
weekly: "毎週",
monthly: "毎月",
once: "1回のみ",
custom: "カスタムcron式",
intervalEvery: "実行間隔",
intervalUnit: "単位",
unitMinutes: "分",
unitHours: "時間",
unitDays: "日",
timeOfDay: "時刻",
weekdays: "曜日",
weekdaysShort: ["日", "月", "火", "水", "木", "金", "土"],
dayOfMonth: "日付",
onceAt: "実行日時",
customLabel: "cron式",
customPlaceholder: "0 9 * * *",
customHint: "5フィールドのcron式分、時、日、月、曜日。",
preview: "送信形式",
previewEmpty: "(未入力)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n}分ごと",
everyHours: "{n}時間ごと",
everyDays: "{n}日ごと",
dailyAt: "毎日 {time}",
weeklyAt: "毎週 {days} {time}",
monthlyAt: "毎月{day} {time}",
onceAt: "{time} に1回",
},
deliverTo: "配信先",
scheduledJobs: "スケジュール済みジョブ",
noJobs: "Cron ジョブが設定されていません。上で作成してください。",

View File

@ -141,6 +141,22 @@ export const ko: Translations = {
"이 작업은 대화와 모든 메시지를 영구적으로 제거합니다. 되돌릴 수 없습니다.",
sessionDeleted: "세션이 삭제되었습니다",
failedToDelete: "세션 삭제에 실패했습니다",
deleteEmpty: "빈 세션 삭제",
deleteEmptyConfirmTitle: "빈 세션을 삭제하시겠습니까?",
deleteEmptyConfirmMessage:
"메시지가 없는 {count}개의 세션을 영구적으로 삭제합니다. 활성 및 보관된 세션은 건너뜁니다. 이 작업은 되돌릴 수 없습니다.",
emptySessionsDeleted: "빈 세션 {count}개 삭제됨",
failedToDeleteEmpty: "빈 세션 삭제에 실패했습니다",
selectSession: "세션 선택",
selectAllOnPage: "이 페이지 전체 선택",
clearSelection: "선택 해제",
selectedCount: "{count}개 선택됨",
deleteSelected: "{count}개 삭제",
deleteSelectedConfirmTitle: "{count}개 세션을 삭제하시겠습니까?",
deleteSelectedConfirmMessage:
"선택한 {count}개 세션과 모든 메시지가 영구적으로 제거됩니다. 이 작업은 취소할 수 없습니다.",
selectedSessionsDeleted: "{count}개 세션이 삭제되었습니다",
failedToDeleteSelected: "선택한 세션 삭제에 실패했습니다",
resumeInChat: "채팅에서 다시 시작",
previousPage: "이전 페이지",
nextPage: "다음 페이지",
@ -211,6 +227,40 @@ export const ko: Translations = {
promptPlaceholder: "에이전트가 매 실행 시 무엇을 해야 합니까?",
schedule: "스케줄 (cron 표현식)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "일정",
scheduleModes: {
interval: "반복 간격",
daily: "매일",
weekly: "매주",
monthly: "매월",
once: "한 번",
custom: "사용자 지정 (cron 표현식)",
intervalEvery: "실행 간격",
intervalUnit: "단위",
unitMinutes: "분",
unitHours: "시간",
unitDays: "일",
timeOfDay: "시각",
weekdays: "요일",
weekdaysShort: ["일", "월", "화", "수", "목", "금", "토"],
dayOfMonth: "날짜",
onceAt: "실행 시각",
customLabel: "cron 표현식",
customPlaceholder: "0 9 * * *",
customHint: "5개 필드의 cron 표현식 (분, 시, 일, 월, 요일).",
preview: "전송 형식",
previewEmpty: "(미완성)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n}분마다",
everyHours: "{n}시간마다",
everyDays: "{n}일마다",
dailyAt: "매일 {time}",
weeklyAt: "매주 {days} {time}",
monthlyAt: "매월 {day} {time}",
onceAt: "{time}에 한 번",
},
deliverTo: "전달 대상",
scheduledJobs: "예약된 작업",
noJobs: "구성된 cron 작업이 없습니다. 위에서 하나 만드세요.",

View File

@ -141,6 +141,22 @@ export const pt: Translations = {
"Esta ação remove permanentemente a conversa e todas as suas mensagens. Não é possível anular.",
sessionDeleted: "Sessão eliminada",
failedToDelete: "Falha ao eliminar a sessão",
deleteEmpty: "Eliminar vazias",
deleteEmptyConfirmTitle: "Eliminar sessões vazias?",
deleteEmptyConfirmMessage:
"Isto remove permanentemente {count} sessões sem mensagens. As sessões ativas e arquivadas são ignoradas. Esta ação não pode ser desfeita.",
emptySessionsDeleted: "{count} sessões vazias eliminadas",
failedToDeleteEmpty: "Falha ao eliminar sessões vazias",
selectSession: "Selecionar sessão",
selectAllOnPage: "Selecionar todas nesta página",
clearSelection: "Limpar seleção",
selectedCount: "{count} selecionadas",
deleteSelected: "Eliminar {count}",
deleteSelectedConfirmTitle: "Eliminar {count} sessões?",
deleteSelectedConfirmMessage:
"Isto remove permanentemente {count} sessões selecionadas e todas as suas mensagens. Não pode ser desfeito.",
selectedSessionsDeleted: "{count} sessões eliminadas",
failedToDeleteSelected: "Falha ao eliminar as sessões selecionadas",
resumeInChat: "Retomar no Chat",
previousPage: "Página anterior",
nextPage: "Página seguinte",
@ -211,6 +227,41 @@ export const pt: Translations = {
promptPlaceholder: "O que deve o agente fazer em cada execução?",
schedule: "Agendamento (expressão cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Agendamento",
scheduleModes: {
interval: "Intervalo recorrente",
daily: "Diariamente",
weekly: "Semanalmente",
monthly: "Mensalmente",
once: "Uma vez",
custom: "Personalizado (expressão cron)",
intervalEvery: "A cada",
intervalUnit: "Unidade",
unitMinutes: "minutos",
unitHours: "horas",
unitDays: "dias",
timeOfDay: "Hora do dia",
weekdays: "Dias da semana",
weekdaysShort: ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"],
dayOfMonth: "Dia do mês",
onceAt: "Executar em",
customLabel: "Expressão cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expressão cron de cinco campos (minuto, hora, dia, mês, dia da semana).",
preview: "Enviado como",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "A cada {n} min",
everyHours: "A cada {n} h",
everyDays: "A cada {n} d",
dailyAt: "Diariamente às {time}",
weeklyAt: "Semanalmente {days} às {time}",
monthlyAt: "Mensalmente no dia {day} às {time}",
onceAt: "Uma vez em {time}",
},
deliverTo: "Entregar a",
scheduledJobs: "Tarefas agendadas",
noJobs: "Sem tarefas cron configuradas. Crie uma acima.",

View File

@ -141,6 +141,22 @@ export const ru: Translations = {
"Это безвозвратно удалит разговор и все его сообщения. Действие нельзя отменить.",
sessionDeleted: "Сессия удалена",
failedToDelete: "Не удалось удалить сессию",
deleteEmpty: "Удалить пустые",
deleteEmptyConfirmTitle: "Удалить пустые сессии?",
deleteEmptyConfirmMessage:
"Это безвозвратно удалит {count} сессий без сообщений. Активные и архивные сессии будут пропущены. Это действие нельзя отменить.",
emptySessionsDeleted: "Удалено пустых сессий: {count}",
failedToDeleteEmpty: "Не удалось удалить пустые сессии",
selectSession: "Выбрать сессию",
selectAllOnPage: "Выбрать все на этой странице",
clearSelection: "Снять выделение",
selectedCount: "Выбрано: {count}",
deleteSelected: "Удалить {count}",
deleteSelectedConfirmTitle: "Удалить {count} сессий?",
deleteSelectedConfirmMessage:
"Это безвозвратно удалит {count} выбранных сессий и все их сообщения. Это действие нельзя отменить.",
selectedSessionsDeleted: "Удалено сессий: {count}",
failedToDeleteSelected: "Не удалось удалить выбранные сессии",
resumeInChat: "Продолжить в чате",
previousPage: "Предыдущая страница",
nextPage: "Следующая страница",
@ -211,6 +227,41 @@ export const ru: Translations = {
promptPlaceholder: "Что должен делать агент при каждом запуске?",
schedule: "Расписание (cron-выражение)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Расписание",
scheduleModes: {
interval: "Повторяющийся интервал",
daily: "Ежедневно",
weekly: "Еженедельно",
monthly: "Ежемесячно",
once: "Один раз",
custom: "Произвольное (cron-выражение)",
intervalEvery: "Каждые",
intervalUnit: "Единицы",
unitMinutes: "минут",
unitHours: "часов",
unitDays: "дней",
timeOfDay: "Время суток",
weekdays: "Дни недели",
weekdaysShort: ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"],
dayOfMonth: "День месяца",
onceAt: "Выполнить в",
customLabel: "Cron-выражение",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-выражение из пяти полей (минута, час, день, месяц, день недели).",
preview: "Отправляется как",
previewEmpty: "(не заполнено)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Каждые {n} мин",
everyHours: "Каждые {n} ч",
everyDays: "Каждые {n} дн",
dailyAt: "Ежедневно в {time}",
weeklyAt: "Еженедельно в {days} в {time}",
monthlyAt: "Ежемесячно {day} числа в {time}",
onceAt: "Один раз {time}",
},
deliverTo: "Доставить в",
scheduledJobs: "Запланированные задачи",
noJobs: "Cron-задачи не настроены. Создайте задачу выше.",

View File

@ -141,6 +141,22 @@ export const tr: Translations = {
"Bu, konuşmayı ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.",
sessionDeleted: "Oturum silindi",
failedToDelete: "Oturum silinemedi",
deleteEmpty: "Boşları sil",
deleteEmptyConfirmTitle: "Boş oturumlar silinsin mi?",
deleteEmptyConfirmMessage:
"Bu işlem, mesaj içermeyen {count} oturumu kalıcı olarak siler. Aktif ve arşivlenmiş oturumlar atlanır. Bu işlem geri alınamaz.",
emptySessionsDeleted: "{count} boş oturum silindi",
failedToDeleteEmpty: "Boş oturumlar silinemedi",
selectSession: "Oturumu seç",
selectAllOnPage: "Bu sayfadakilerin tümünü seç",
clearSelection: "Seçimi temizle",
selectedCount: "{count} seçildi",
deleteSelected: "{count} sil",
deleteSelectedConfirmTitle: "{count} oturum silinsin mi?",
deleteSelectedConfirmMessage:
"Bu, seçilen {count} oturumu ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.",
selectedSessionsDeleted: "{count} oturum silindi",
failedToDeleteSelected: "Seçilen oturumlar silinemedi",
resumeInChat: "Sohbette Devam Et",
previousPage: "Önceki sayfa",
nextPage: "Sonraki sayfa",
@ -211,6 +227,41 @@ export const tr: Translations = {
promptPlaceholder: "Agent her çalıştırmada ne yapmalı?",
schedule: "Zamanlama (cron ifadesi)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Zamanlama",
scheduleModes: {
interval: "Tekrarlanan aralık",
daily: "Günlük",
weekly: "Haftalık",
monthly: "Aylık",
once: "Bir kez",
custom: "Özel (cron ifadesi)",
intervalEvery: "Her",
intervalUnit: "Birim",
unitMinutes: "dakika",
unitHours: "saat",
unitDays: "gün",
timeOfDay: "Günün saati",
weekdays: "Haftanın günleri",
weekdaysShort: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"],
dayOfMonth: "Ayın günü",
onceAt: "Çalıştırma zamanı",
customLabel: "Cron ifadesi",
customPlaceholder: "0 9 * * *",
customHint:
"Beş alanlı cron ifadesi (dakika, saat, gün, ay, haftanın günü).",
preview: "Gönderilecek olan",
previewEmpty: "(eksik)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Her {n} dk",
everyHours: "Her {n} sa",
everyDays: "Her {n} gün",
dailyAt: "Her gün {time}",
weeklyAt: "Her hafta {days} {time}",
monthlyAt: "Her ayın {day} günü {time}",
onceAt: "{time} bir kez",
},
deliverTo: "Şuraya teslim et",
scheduledJobs: "Zamanlanmış Görevler",
noJobs: "Yapılandırılmış cron görevi yok. Yukarıdan bir tane oluşturun.",

View File

@ -158,6 +158,20 @@ export interface Translations {
confirmDeleteMessage: string;
sessionDeleted: string;
failedToDelete: string;
deleteEmpty: string;
deleteEmptyConfirmTitle: string;
deleteEmptyConfirmMessage: string;
emptySessionsDeleted: string;
failedToDeleteEmpty: string;
selectSession: string;
selectAllOnPage: string;
clearSelection: string;
selectedCount: string;
deleteSelected: string;
deleteSelectedConfirmTitle: string;
deleteSelectedConfirmMessage: string;
selectedSessionsDeleted: string;
failedToDeleteSelected: string;
resumeInChat: string;
previousPage: string;
nextPage: string;
@ -231,6 +245,40 @@ export interface Translations {
promptPlaceholder: string;
schedule: string;
schedulePlaceholder: string;
scheduleMode: string;
scheduleModes: {
interval: string;
daily: string;
weekly: string;
monthly: string;
once: string;
custom: string;
intervalEvery: string;
intervalUnit: string;
unitMinutes: string;
unitHours: string;
unitDays: string;
timeOfDay: string;
weekdays: string;
weekdaysShort: [string, string, string, string, string, string, string];
dayOfMonth: string;
onceAt: string;
customLabel: string;
customPlaceholder: string;
customHint: string;
preview: string;
previewEmpty: string;
};
scheduleDescribe: {
none: string;
everyMinutes: string;
everyHours: string;
everyDays: string;
dailyAt: string;
weeklyAt: string;
monthlyAt: string;
onceAt: string;
};
deliverTo: string;
scheduledJobs: string;
noJobs: string;

View File

@ -141,6 +141,22 @@ export const uk: Translations = {
"Це назавжди видалить розмову та всі її повідомлення. Цю дію не можна скасувати.",
sessionDeleted: "Сесію видалено",
failedToDelete: "Не вдалося видалити сесію",
deleteEmpty: "Видалити порожні",
deleteEmptyConfirmTitle: "Видалити порожні сесії?",
deleteEmptyConfirmMessage:
"Це остаточно видалить {count} сесій без повідомлень. Активні та архівні сесії пропускаються. Цю дію неможливо скасувати.",
emptySessionsDeleted: "Видалено порожніх сесій: {count}",
failedToDeleteEmpty: "Не вдалося видалити порожні сесії",
selectSession: "Вибрати сесію",
selectAllOnPage: "Вибрати всі на цій сторінці",
clearSelection: "Скинути вибір",
selectedCount: "Вибрано: {count}",
deleteSelected: "Видалити {count}",
deleteSelectedConfirmTitle: "Видалити {count} сесій?",
deleteSelectedConfirmMessage:
"Це назавжди видалить {count} вибраних сесій і всі їхні повідомлення. Цю дію неможливо скасувати.",
selectedSessionsDeleted: "Видалено сесій: {count}",
failedToDeleteSelected: "Не вдалося видалити вибрані сесії",
resumeInChat: "Продовжити в чаті",
previousPage: "Попередня сторінка",
nextPage: "Наступна сторінка",
@ -211,6 +227,41 @@ export const uk: Translations = {
promptPlaceholder: "Що агент має робити при кожному запуску?",
schedule: "Розклад (cron-вираз)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Розклад",
scheduleModes: {
interval: "Повторюваний інтервал",
daily: "Щодня",
weekly: "Щотижня",
monthly: "Щомісяця",
once: "Один раз",
custom: "Користувацьке (cron-вираз)",
intervalEvery: "Кожні",
intervalUnit: "Одиниці",
unitMinutes: "хвилин",
unitHours: "годин",
unitDays: "днів",
timeOfDay: "Час доби",
weekdays: "Дні тижня",
weekdaysShort: ["Нд", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"],
dayOfMonth: "День місяця",
onceAt: "Виконати о",
customLabel: "Cron-вираз",
customPlaceholder: "0 9 * * *",
customHint:
"П'ятиполевий cron-вираз (хвилина, година, день, місяць, день тижня).",
preview: "Надсилається як",
previewEmpty: "(не заповнено)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Кожні {n} хв",
everyHours: "Кожні {n} год",
everyDays: "Кожні {n} дн",
dailyAt: "Щодня о {time}",
weeklyAt: "Щотижня у {days} о {time}",
monthlyAt: "Щомісяця {day} числа о {time}",
onceAt: "Один раз {time}",
},
deliverTo: "Надіслати на",
scheduledJobs: "Заплановані завдання",
noJobs: "Cron-завдань не налаштовано. Створіть одне вище.",

View File

@ -141,6 +141,22 @@ export const zhHant: Translations = {
"此操作將永久移除對話及其所有訊息,無法復原。",
sessionDeleted: "工作階段已刪除",
failedToDelete: "刪除工作階段失敗",
deleteEmpty: "刪除空工作階段",
deleteEmptyConfirmTitle: "刪除空工作階段?",
deleteEmptyConfirmMessage:
"這將永久刪除 {count} 個沒有訊息的工作階段。活動中與已封存的工作階段將被略過。此動作無法復原。",
emptySessionsDeleted: "已刪除 {count} 個空工作階段",
failedToDeleteEmpty: "刪除空工作階段失敗",
selectSession: "選擇工作階段",
selectAllOnPage: "全選本頁",
clearSelection: "清除選擇",
selectedCount: "已選擇 {count} 個",
deleteSelected: "刪除 {count} 個",
deleteSelectedConfirmTitle: "刪除 {count} 個工作階段?",
deleteSelectedConfirmMessage:
"此操作將永久刪除所選的 {count} 個工作階段及其所有訊息。無法復原。",
selectedSessionsDeleted: "已刪除 {count} 個工作階段",
failedToDeleteSelected: "刪除所選工作階段失敗",
resumeInChat: "在對話中繼續",
previousPage: "上一頁",
nextPage: "下一頁",
@ -211,6 +227,40 @@ export const zhHant: Translations = {
promptPlaceholder: "代理每次執行時應做什麼?",
schedule: "排程cron 運算式)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "排程",
scheduleModes: {
interval: "重複間隔",
daily: "每日",
weekly: "每週",
monthly: "每月",
once: "僅一次",
custom: "自訂cron 運算式)",
intervalEvery: "每",
intervalUnit: "單位",
unitMinutes: "分鐘",
unitHours: "小時",
unitDays: "天",
timeOfDay: "時間",
weekdays: "星期",
weekdaysShort: ["日", "一", "二", "三", "四", "五", "六"],
dayOfMonth: "日期",
onceAt: "執行時間",
customLabel: "cron 運算式",
customPlaceholder: "0 9 * * *",
customHint: "五欄位 cron 運算式(分、時、日、月、星期)。",
preview: "傳送為",
previewEmpty: "(未完成)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "每 {n} 分鐘",
everyHours: "每 {n} 小時",
everyDays: "每 {n} 天",
dailyAt: "每天 {time}",
weeklyAt: "每週 {days} {time}",
monthlyAt: "每月{day} {time}",
onceAt: "{time} 執行一次",
},
deliverTo: "傳送至",
scheduledJobs: "已排程任務",
noJobs: "尚未設定排程任務。請於上方建立。",

View File

@ -139,6 +139,22 @@ export const zh: Translations = {
confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。",
sessionDeleted: "会话已删除",
failedToDelete: "删除会话失败",
deleteEmpty: "删除空会话",
deleteEmptyConfirmTitle: "删除空会话?",
deleteEmptyConfirmMessage:
"这将永久删除 {count} 个没有消息的会话。活动和已归档的会话将被跳过。此操作无法撤销。",
emptySessionsDeleted: "已删除 {count} 个空会话",
failedToDeleteEmpty: "删除空会话失败",
selectSession: "选择会话",
selectAllOnPage: "全选本页",
clearSelection: "清除选择",
selectedCount: "已选择 {count} 个",
deleteSelected: "删除 {count} 个",
deleteSelectedConfirmTitle: "删除 {count} 个会话?",
deleteSelectedConfirmMessage:
"此操作将永久删除所选的 {count} 个会话及其所有消息。无法撤销。",
selectedSessionsDeleted: "已删除 {count} 个会话",
failedToDeleteSelected: "删除所选会话失败",
resumeInChat: "在对话中继续",
previousPage: "上一页",
nextPage: "下一页",
@ -208,6 +224,40 @@ export const zh: Translations = {
promptPlaceholder: "代理每次运行时应执行什么操作?",
schedule: "调度表达式cron",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "调度",
scheduleModes: {
interval: "重复间隔",
daily: "每天",
weekly: "每周",
monthly: "每月",
once: "仅一次",
custom: "自定义cron 表达式)",
intervalEvery: "每",
intervalUnit: "单位",
unitMinutes: "分钟",
unitHours: "小时",
unitDays: "天",
timeOfDay: "时间",
weekdays: "星期",
weekdaysShort: ["日", "一", "二", "三", "四", "五", "六"],
dayOfMonth: "日期",
onceAt: "执行时间",
customLabel: "cron 表达式",
customPlaceholder: "0 9 * * *",
customHint: "五字段 cron 表达式(分、时、日、月、星期)。",
preview: "发送为",
previewEmpty: "(未完成)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "每 {n} 分钟",
everyHours: "每 {n} 小时",
everyDays: "每 {n} 天",
dailyAt: "每天 {time}",
weeklyAt: "每周 {days} {time}",
monthlyAt: "每月{day} {time}",
onceAt: "{time} 执行一次",
},
deliverTo: "投递至",
scheduledJobs: "已调度任务",
noJobs: "暂无定时任务。在上方创建一个。",

View File

@ -83,6 +83,15 @@
--theme-radius: 0.5rem;
--theme-spacing-mul: 1;
--theme-density: comfortable;
/* Data-series accents — consumed by Analytics + Models pages for the
input-vs-output token visualisations (chart bars, table values,
legend swatches). Defaults are tuned for the Hermes-teal LENS_0
look: cream input + emerald-400 output read as warm/cool against
the dark canvas. Themes override via ThemeProvider, which emits
these as `--series-input-token` / `--series-output-token`. */
--series-input-token: #ffe6cb;
--series-output-token: #34d399;
}
/* Theme tokens cascade into the document root so every descendant inherits

View File

@ -238,6 +238,18 @@ export const api = {
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
getEmptySessionsCount: () =>
fetchJSON<{ count: number }>("/api/sessions/empty/count"),
deleteEmptySessions: () =>
fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/empty", {
method: "DELETE",
}),
bulkDeleteSessions: (ids: string[]) =>
fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
}),
renameSession: (id: string, title: string) =>
fetchJSON<{ ok: boolean; title: string }>(
`/api/sessions/${encodeURIComponent(id)}`,

382
web/src/lib/schedule.ts Normal file
View File

@ -0,0 +1,382 @@
/**
* Schedule builder helpers for the cron page.
*
* The hermes-agent backend (cron/jobs.py::parse_schedule) accepts a
* surprisingly broad set of string formats:
*
* - Duration (one-shot): "30m", "2h", "1d"
* - Interval (recurring): "every 30m", "every 2h", "every 1d"
* - Cron expression (5-field): "0 9 * * *", "30 14 * * 1,3,5"
* - ISO timestamp (one-shot): "2026-02-03T14:00:00"
*
* Power users can hand-type any of those, but for everyone else the
* dashboard now offers a human-readable picker. This module is the
* pure logic layer behind that picker:
*
* - {@link buildScheduleString} turns the picker's structured state
* into one of the strings above.
* - {@link describeSchedule} goes the other way: takes the structured
* schedule shape the API returns (``CronJob.schedule``) and produces
* a human-readable sentence for the job list. It recognises common
* cron-expression shapes (daily/weekly/monthly) so users don't have
* to parse "30 14 * * 1,3,5" by eye.
*
* Kept dependency-free and locale-string-driven so it tree-shakes
* cleanly and is testable in isolation if we ever wire up vitest here.
*/
/** Picker modes — each renders a different set of inputs in the UI but
* all funnel through {@link buildScheduleString} to a backend-compatible
* string. ``custom`` is the escape hatch for power users who still want
* to type a raw cron expression. */
export type ScheduleMode =
| "interval"
| "daily"
| "weekly"
| "monthly"
| "once"
| "custom";
/** Unit used by interval mode. Backend parses ``m``/``h``/``d`` suffixes. */
export type IntervalUnit = "minutes" | "hours" | "days";
/** Cron weekday convention: Sunday = 0 .. Saturday = 6. Matches what
* croniter expects on the backend (no need to remap on submit). */
export const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const;
export type Weekday = (typeof WEEKDAY_INDEXES)[number];
export interface ScheduleBuilderState {
/** Index of which "custom" radio is selected. */
mode: ScheduleMode;
/** Interval mode: positive integer, paired with ``intervalUnit``. */
intervalValue: number;
intervalUnit: IntervalUnit;
/** Daily/weekly/monthly mode: "HH:MM" 24h format from <input type=time>. */
timeOfDay: string;
/** Weekly mode: 0..6, Sunday-first. Empty means "every day", which is
* still valid — we send "*" for the day-of-week cron field. */
weekdays: Weekday[];
/** Monthly mode: 1..31 (no support for "last day of month" sugar — the
* croniter ``L`` extension isn't enabled in the parse_schedule regex). */
dayOfMonth: number;
/** Once mode: ``YYYY-MM-DDTHH:MM`` from <input type=datetime-local>. */
onceAt: string;
/** Custom mode: raw user-typed cron expression. Stored separately so
* flipping between modes doesn't erase the user's work. */
custom: string;
}
/** Default state — "every 30 minutes" is the most-common-cron-pattern
* starting point and avoids forcing the user to pick everything from
* scratch. */
export const DEFAULT_SCHEDULE_STATE: ScheduleBuilderState = {
mode: "interval",
intervalValue: 30,
intervalUnit: "minutes",
timeOfDay: "09:00",
weekdays: [1, 2, 3, 4, 5],
dayOfMonth: 1,
onceAt: "",
custom: "",
};
const UNIT_SUFFIX: Record<IntervalUnit, string> = {
minutes: "m",
hours: "h",
days: "d",
};
/** Build the schedule string from picker state. Returns ``""`` when the
* state is incomplete enough that the backend would 400 — the caller
* uses that to disable the Submit button.
*
* Why we lean on the broad parse_schedule grammar instead of always
* emitting cron expressions: interval syntax ("every 30m") survives a
* backend without ``croniter`` installed and renders more readably in
* the job list. We only emit raw cron when the picker truly needs the
* cron field expressiveness (specific weekdays, specific day-of-month). */
export function buildScheduleString(state: ScheduleBuilderState): string {
switch (state.mode) {
case "interval": {
const n = Math.floor(state.intervalValue);
if (!Number.isFinite(n) || n < 1) return "";
return `every ${n}${UNIT_SUFFIX[state.intervalUnit]}`;
}
case "daily": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
return `${parsed.minute} ${parsed.hour} * * *`;
}
case "weekly": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
// Empty weekday selection → "*" (every day) rather than a backend
// 400. The Daily mode is the cleaner choice for that, but if the
// user toggles all days off in Weekly mode we still emit a valid
// expression instead of breaking the submit.
const days =
state.weekdays.length === 0
? "*"
: [...state.weekdays].sort((a, b) => a - b).join(",");
return `${parsed.minute} ${parsed.hour} * * ${days}`;
}
case "monthly": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
const dom = Math.floor(state.dayOfMonth);
if (!Number.isFinite(dom) || dom < 1 || dom > 31) return "";
return `${parsed.minute} ${parsed.hour} ${dom} * *`;
}
case "once": {
const v = state.onceAt.trim();
if (!v) return "";
// <input type=datetime-local> already emits the
// "YYYY-MM-DDTHH:MM" shape that fromisoformat() accepts directly.
// Append ":00" so the backend's regex hits the "T" branch and
// the seconds component lines up with isoformat() output.
return v.length === 16 ? `${v}:00` : v;
}
case "custom":
return state.custom.trim();
}
}
function parseTimeOfDay(value: string): { hour: number; minute: number } | null {
if (!value || !/^\d{1,2}:\d{2}$/.test(value)) return null;
const [hh, mm] = value.split(":");
const hour = parseInt(hh, 10);
const minute = parseInt(mm, 10);
if (
!Number.isFinite(hour) ||
!Number.isFinite(minute) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return null;
}
return { hour, minute };
}
/** Translation surface the human-readable describer needs. Passing it
* in (instead of importing ``useI18n``) keeps the helper pure and
* testable; the CronPage threads ``t.cron.scheduleDescribe`` through. */
export interface ScheduleDescribeStrings {
/** Display when no schedule can be resolved (e.g. legacy/blank job). */
none: string;
/** "Every {n} minute(s)" — caller pluralises via {n}. */
everyMinutes: string;
everyHours: string;
everyDays: string;
/** "Daily at {time}" */
dailyAt: string;
/** "Weekly on {days} at {time}" */
weeklyAt: string;
/** "Monthly on the {day} at {time}" */
monthlyAt: string;
/** "Once at {time}" */
onceAt: string;
/** Weekday short names indexed 0..6 (Sunday-first). */
weekdaysShort: [string, string, string, string, string, string, string];
/** Ordinal suffix builder, e.g. "1st", "22nd". For locales that
* don't use English ordinals, just return ``String(day)``. */
ordinal: (day: number) => string;
}
/** Schedule shape stored on a ``CronJob`` row (see api.ts). */
export interface ScheduleLike {
kind?: string;
expr?: string;
minutes?: number;
run_at?: string;
display?: string;
}
/** Human-readable description of a stored schedule.
*
* Prefers a structured render over the raw ``display`` string so cron
* expressions like ``30 14 * * 1,3,5`` show up as "Weekly on Mon, Wed,
* Fri at 14:30" instead of the raw five-field gibberish. Falls back to
* ``display`` / ``expr`` / ``none`` in that order if we can't make sense
* of the schedule (e.g. exotic cron with ranges, step values, or @reboot
* macros that we'd misrepresent if we tried to "humanize"). */
export function describeSchedule(
schedule: ScheduleLike | undefined,
fallbackDisplay: string | undefined,
strings: ScheduleDescribeStrings,
): string {
if (!schedule) return fallbackDisplay || strings.none;
if (schedule.kind === "interval" && typeof schedule.minutes === "number") {
return describeInterval(schedule.minutes, strings);
}
if (schedule.kind === "once" && schedule.run_at) {
return strings.onceAt.replace(
"{time}",
formatIsoLocal(schedule.run_at, false),
);
}
if (schedule.kind === "cron" && schedule.expr) {
const cronDesc = describeCronExpression(schedule.expr, strings);
if (cronDesc) return cronDesc;
}
// Try the raw expression as a last attempt — for legacy jobs stored
// without ``kind``, the ``schedule_display`` field often *is* the cron
// expression.
if (fallbackDisplay) {
const cronDesc = describeCronExpression(fallbackDisplay, strings);
if (cronDesc) return cronDesc;
return fallbackDisplay;
}
if (schedule.display) return schedule.display;
if (schedule.expr) return schedule.expr;
return strings.none;
}
function describeInterval(
minutes: number,
strings: ScheduleDescribeStrings,
): string {
if (minutes <= 0) return strings.none;
if (minutes % 1440 === 0) {
return strings.everyDays.replace("{n}", String(minutes / 1440));
}
if (minutes % 60 === 0) {
return strings.everyHours.replace("{n}", String(minutes / 60));
}
return strings.everyMinutes.replace("{n}", String(minutes));
}
/** Recognise the common, well-shaped cron patterns and return a
* human sentence for them. Returns ``null`` when the expression has any
* ranges, steps, or other complexity that would be misleading to
* "humanize" — caller falls back to displaying the raw expression so
* the user sees what's actually scheduled.
*
* Strictly 5-field only: the backend ``parse_schedule`` also accepts the
* 6-field ``minute hour dom month dow year`` form, but humanising those
* by destructuring only the first five fields would silently drop the
* year and mislead the user (e.g. ``0 9 * * * 2099`` would read as
* "Daily at 09:00"). 6+ field expressions intentionally fall through to
* the raw-string fallback in {@link describeSchedule}. */
function describeCronExpression(
expr: string,
strings: ScheduleDescribeStrings,
): string | null {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minField, hourField, domField, monField, dowField] = parts;
const month = monField === "*";
if (!month) return null; // we don't try to humanize per-month rules
const isLiteralOrList = (f: string) =>
/^\d+(,\d+)*$/.test(f) || /^\*$/.test(f);
if (!isLiteralOrList(minField) || !isLiteralOrList(hourField)) return null;
if (!isLiteralOrList(domField) || !isLiteralOrList(dowField)) return null;
// Star minutes/hours would mean "every minute" / "every hour" — we'd
// need a step-value handler ("*/15") to describe that cleanly, and
// that path is power-user territory. Bail to raw display.
if (minField === "*" || hourField === "*") return null;
const minutes = minField.split(",").map((n) => parseInt(n, 10));
const hours = hourField.split(",").map((n) => parseInt(n, 10));
if (minutes.length !== 1 || hours.length !== 1) return null;
if (
!Number.isFinite(minutes[0]) ||
!Number.isFinite(hours[0]) ||
hours[0] < 0 ||
hours[0] > 23 ||
minutes[0] < 0 ||
minutes[0] > 59
) {
return null;
}
const time = `${pad2(hours[0])}:${pad2(minutes[0])}`;
const domAll = domField === "*";
const dowAll = dowField === "*";
if (domAll && dowAll) {
return strings.dailyAt.replace("{time}", time);
}
if (domAll && !dowAll) {
const days = dowField
.split(",")
.map((n) => parseInt(n, 10))
.filter((n) => Number.isFinite(n) && n >= 0 && n <= 6) as Weekday[];
if (days.length === 0) return null;
const labels = days
.map((d) => strings.weekdaysShort[d])
.filter(Boolean)
.join(", ");
return strings.weeklyAt
.replace("{days}", labels)
.replace("{time}", time);
}
if (!domAll && dowAll) {
const dom = parseInt(domField, 10);
if (!Number.isFinite(dom) || dom < 1 || dom > 31) return null;
return strings.monthlyAt
.replace("{day}", strings.ordinal(dom))
.replace("{time}", time);
}
// Both day-of-month AND day-of-week set is unusual and cron's
// OR-semantics for that combo are confusing — fall back to raw.
return null;
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
/** Format an ISO date for inline display. Drops the seconds + TZ
* suffix so the cron list stays compact. Falls back to the raw string
* if Date parsing fails. */
function formatIsoLocal(iso: string, includeSeconds: boolean): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const yyyy = d.getFullYear();
const mm = pad2(d.getMonth() + 1);
const dd = pad2(d.getDate());
const hh = pad2(d.getHours());
const mi = pad2(d.getMinutes());
if (includeSeconds) {
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${pad2(d.getSeconds())}`;
}
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
}
/** Convenience: build an English ordinal suffix ("1st", "2nd", "23rd").
* Most non-English locales should just return ``String(day)`` from
* their ``ordinal`` override. */
export function englishOrdinal(day: number): string {
const d = Math.floor(day);
if (!Number.isFinite(d) || d < 1) return String(day);
const lastTwo = d % 100;
if (lastTwo >= 11 && lastTwo <= 13) return `${d}th`;
switch (d % 10) {
case 1:
return `${d}st`;
case 2:
return `${d}nd`;
case 3:
return `${d}rd`;
default:
return `${d}th`;
}
}

View File

@ -21,7 +21,6 @@ import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Stats } from "@nous-research/ui/ui/components/stats";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
@ -148,11 +147,17 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
</div>
<div className="flex items-center gap-4 font-mondwest normal-case text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
<div
className="h-2.5 w-2.5"
style={{ backgroundColor: "var(--series-input-token)" }}
/>
{t.analytics.input}
</div>
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-emerald-500" />
<div
className="h-2.5 w-2.5"
style={{ backgroundColor: "var(--series-output-token)" }}
/>
{t.analytics.output}
</div>
</div>
@ -192,13 +197,19 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
</div>
<div
className="w-full bg-[#ffe6cb]/70"
style={{ height: Math.max(inputH, total > 0 ? 1 : 0) }}
className="w-full"
style={{
backgroundColor:
"color-mix(in srgb, var(--series-input-token) 70%, transparent)",
height: Math.max(inputH, total > 0 ? 1 : 0),
}}
/>
<div
className="w-full bg-emerald-500/70"
className="w-full"
style={{
backgroundColor:
"color-mix(in srgb, var(--series-output-token) 70%, transparent)",
height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0),
}}
/>
@ -261,12 +272,12 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
{d.sessions}
</td>
<td className="text-right py-2 px-4">
<span className="text-[#ffe6cb]">
<span style={{ color: "var(--series-input-token)" }}>
{formatTokens(d.input_tokens)}
</span>
</td>
<td className="text-right py-2 pl-4">
<span className="text-emerald-400">
<span style={{ color: "var(--series-output-token)" }}>
{formatTokens(d.output_tokens)}
</span>
</td>
@ -319,11 +330,11 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
{m.sessions}
</td>
<td className="text-right py-2 pl-4">
<span className="text-[#ffe6cb]">
<span style={{ color: "var(--series-input-token)" }}>
{formatTokens(m.input_tokens)}
</span>
{" / "}
<span className="text-emerald-400">
<span style={{ color: "var(--series-output-token)" }}>
{formatTokens(m.output_tokens)}
</span>
</td>
@ -427,14 +438,24 @@ export default function AnalyticsPage() {
}, [days, showTokens]);
useLayoutEffect(() => {
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
// Period selector + refresh both live in afterTitle so the controls
// sit immediately next to the page title instead of being pinned to
// the far-right `end` slot. The active period is conveyed by the
// filled (non-outlined) button — no redundant period badge.
setAfterTitle(
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{periodLabel}
</Badge>
{showTokens !== false && (
showTokens === false ? null : (
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
<Button
type="button"
ghost
@ -446,28 +467,10 @@ export default function AnalyticsPage() {
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
)}
</span>,
);
setEnd(
showTokens === false ? null : (
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
</div>
</div>
),
);
setEnd(null);
return () => {
setAfterTitle(null);
setEnd(null);

View File

@ -8,6 +8,17 @@ import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { CronJob, ProfileInfo } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import {
DEFAULT_SCHEDULE_STATE,
ScheduleBuilder,
} from "@/components/ScheduleBuilder";
import {
buildScheduleString,
describeSchedule,
englishOrdinal,
type ScheduleBuilderState,
type ScheduleDescribeStrings,
} from "@/lib/schedule";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { useModalBehavior } from "@/hooks/useModalBehavior";
@ -57,12 +68,20 @@ function getJobTitle(job: CronJob): string {
return job.id || "Cron job";
}
function getJobScheduleDisplay(job: CronJob): string {
return (
asText(job.schedule_display) ||
asText(job.schedule?.display) ||
asText(job.schedule?.expr) ||
"—"
function getJobScheduleDisplay(
job: CronJob,
strings: ScheduleDescribeStrings,
): string {
// Prefer a structured render so cron expressions like
// ``30 14 * * 1,3,5`` surface as "Weekly on Mon, Wed, Fri at 14:30"
// in the list instead of the raw five-field gibberish. Falls back
// through the existing chain (``schedule_display`` from the backend,
// then the structured ``display`` field, then the raw ``expr``) so
// legacy job rows still render *something* meaningful.
return describeSchedule(
job.schedule,
asText(job.schedule_display) || asText(job.schedule?.display),
strings,
);
}
@ -102,13 +121,35 @@ export default function CronPage() {
const [selectedProfile, setSelectedProfile] = useState("all");
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { t, locale } = useI18n();
const { setEnd } = usePageHeader();
// Translation surface for the human-readable schedule describer.
// English ordinals are a special case ("1st", "2nd", "23rd"); every
// other locale falls back to the plain numeric form, which avoids
// shipping incorrect grammar (e.g. naive "1th"/"2th" suffixes that
// don't exist in most languages).
//
// Built inline (not memoized) — the cron page renders a small job
// list, this is single-digit microseconds, and a useMemo here would
// just add boilerplate.
const scheduleDescribeStrings: ScheduleDescribeStrings = {
...t.cron.scheduleDescribe,
weekdaysShort: t.cron.scheduleModes.weekdaysShort,
ordinal: locale === "en" ? englishOrdinal : (n: number) => String(n),
};
// New job modal state
const [createModalOpen, setCreateModalOpen] = useState(false);
const [prompt, setPrompt] = useState("");
const [schedule, setSchedule] = useState("");
// The schedule is now constructed via the ScheduleBuilder; we keep
// the full builder state so flipping between modes during edit
// doesn't erase the user's intermediate inputs. The actual string
// sent to the backend is derived via ``buildScheduleString`` at
// submit time.
const [scheduleState, setScheduleState] = useState<ScheduleBuilderState>(
DEFAULT_SCHEDULE_STATE,
);
const [name, setName] = useState("");
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
const createModalRef = useModalBehavior({
@ -161,8 +202,10 @@ export default function CronPage() {
loadJobs();
}, [loadJobs]);
const scheduleString = buildScheduleString(scheduleState);
const handleCreate = async () => {
if (!prompt.trim() || !schedule.trim()) {
if (!prompt.trim() || !scheduleString) {
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
return;
}
@ -171,7 +214,7 @@ export default function CronPage() {
await api.createCronJob(
{
prompt: prompt.trim(),
schedule: schedule.trim(),
schedule: scheduleString,
name: name.trim() || undefined,
deliver,
},
@ -179,7 +222,7 @@ export default function CronPage() {
);
showToast(t.common.create + " ✓", "success");
setPrompt("");
setSchedule("");
setScheduleState(DEFAULT_SCHEDULE_STATE);
setName("");
setDeliver("local");
setCreateModalOpen(false);
@ -392,41 +435,34 @@ export default function CronPage() {
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
<Input
id="cron-schedule"
placeholder={t.cron.schedulePlaceholder}
value={schedule}
onChange={(e) => setSchedule(e.target.value)}
/>
</div>
<ScheduleBuilder
value={scheduleState}
onChange={setScheduleState}
/>
<div className="grid gap-2">
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
<Select
id="cron-deliver"
value={deliver}
onValueChange={(v) => setDeliver(v)}
>
<SelectOption value="local">
{t.cron.delivery.local}
</SelectOption>
<SelectOption value="telegram">
{t.cron.delivery.telegram}
</SelectOption>
<SelectOption value="discord">
{t.cron.delivery.discord}
</SelectOption>
<SelectOption value="slack">
{t.cron.delivery.slack}
</SelectOption>
<SelectOption value="email">
{t.cron.delivery.email}
</SelectOption>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
<Select
id="cron-deliver"
value={deliver}
onValueChange={(v) => setDeliver(v)}
>
<SelectOption value="local">
{t.cron.delivery.local}
</SelectOption>
<SelectOption value="telegram">
{t.cron.delivery.telegram}
</SelectOption>
<SelectOption value="discord">
{t.cron.delivery.discord}
</SelectOption>
<SelectOption value="slack">
{t.cron.delivery.slack}
</SelectOption>
<SelectOption value="email">
{t.cron.delivery.email}
</SelectOption>
</Select>
</div>
<div className="flex justify-end">
@ -617,7 +653,9 @@ export default function CronPage() {
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono">{getJobScheduleDisplay(job)}</span>
<span className="font-mono-ui">
{getJobScheduleDisplay(job, scheduleDescribeStrings)}
</span>
<span>
{t.cron.last}: {formatTime(job.last_run_at)}
</span>

View File

@ -95,11 +95,17 @@ function TokenBar({
const total = input + output + cacheRead + reasoning;
if (total === 0) return null;
const segments = [
{ value: cacheRead, color: "bg-blue-400/60", dotColor: "bg-blue-400", label: "Cache Read" },
{ value: reasoning, color: "bg-purple-400/60", dotColor: "bg-purple-400", label: "Reasoning" },
{ value: input, color: "bg-[#ffe6cb]/70", dotColor: "bg-[#ffe6cb]", label: "Input" },
{ value: output, color: "bg-emerald-500/70", dotColor: "bg-emerald-500", label: "Output" },
// Segments carry a CSS color value (hex or `var(--token)`) rather than
// a Tailwind class so the input/output series can pick up the active
// theme's `--series-*-token` vars — see `themes/types.ts`
// `ThemeSeriesColors`. The /60/70 fade on the bar is applied via
// color-mix on the same value so themes don't need to ship two
// separate hex literals.
const segments: Array<{ color: string; label: string; value: number }> = [
{ value: cacheRead, color: "#60a5fa", label: "Cache Read" }, // tailwind blue-400
{ value: reasoning, color: "#c084fc", label: "Reasoning" }, // tailwind purple-400
{ value: input, color: "var(--series-input-token)", label: "Input" },
{ value: output, color: "var(--series-output-token)", label: "Output" },
].filter((s) => s.value > 0);
return (
@ -109,8 +115,11 @@ function TokenBar({
{segments.map((s, i) => (
<div
key={i}
className={`${s.color} relative flex items-center transition-all duration-300`}
style={{ width: `${(s.value / total) * 100}%` }}
className="relative flex items-center transition-all duration-300"
style={{
backgroundColor: `color-mix(in srgb, ${s.color} 70%, transparent)`,
width: `${(s.value / total) * 100}%`,
}}
>
{/* Stepped fill pattern overlay */}
<div
@ -128,7 +137,10 @@ function TokenBar({
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-text-secondary">
{segments.map((s, i) => (
<span key={i} className="flex items-center gap-1">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.dotColor}`} />
<span
className="inline-block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: s.color }}
/>
{s.label} {formatTokens(s.value)}
</span>
))}
@ -152,7 +164,7 @@ function CapabilityBadges({
return (
<div className="flex flex-wrap items-center gap-1.5">
{capabilities.supports_tools && (
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<span className="inline-flex items-center gap-1 bg-success/10 px-1.5 py-0.5 text-xs font-medium text-success">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}
@ -818,13 +830,24 @@ export default function ModelsPage() {
}, []);
useLayoutEffect(() => {
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
// Period selector + refresh both live in afterTitle so the controls
// sit immediately next to the page title instead of being pinned to
// the far-right `end` slot. The active period is conveyed by the
// filled (non-outlined) button — no redundant period badge.
setAfterTitle(
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{periodLabel}
</Badge>
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
className="uppercase"
>
{p.label}
</Button>
))}
<Button
type="button"
ghost
@ -836,26 +859,9 @@ export default function ModelsPage() {
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
className="uppercase"
>
{p.label}
</Button>
))}
</div>
</div>,
);
setEnd(null);
return () => {
setAfterTitle(null);
setEnd(null);

View File

@ -23,6 +23,7 @@ import {
Hash,
X,
Play,
Eraser,
Download,
Pencil,
Check,
@ -41,6 +42,7 @@ import { Markdown } from "@/components/Markdown";
import { PlatformsCard } from "@/components/PlatformsCard";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Button } from "@nous-research/ui/ui/components/button";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Segmented } from "@nous-research/ui/ui/components/segmented";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
@ -273,22 +275,14 @@ function SessionRow({
snippet,
searchQuery,
isExpanded,
isSelected,
onToggle,
onSelectClick,
onDelete,
onRename,
onExport,
resumeInChatEnabled,
}: {
session: SessionInfo;
snippet?: string;
searchQuery?: string;
isExpanded: boolean;
onToggle: () => void;
onDelete: () => void;
onRename: (id: string, title: string) => Promise<void>;
onExport: (id: string) => void;
resumeInChatEnabled: boolean;
}) {
}: SessionRowProps) {
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -400,18 +394,44 @@ function SessionRow({
</>
);
// Selected rows get a stronger left-edge accent + tinted background so the
// selection state is unambiguous even when scrolling past the bulk-action
// bar at the top. Beat the is_active styling — explicit user selection
// takes priority over "this session is live".
const containerClasses = isSelected
? "border-primary/40 bg-primary/[0.06]"
: session.is_active
? "border-success/30 bg-success/[0.03]"
: "border-border";
// Clicking the checkbox must NOT toggle row expansion; selection and
// expansion are independent gestures. We bind ``onClick`` directly on
// the Checkbox (which Radix forwards to its underlying ``<button
// role=checkbox>``) so the event carries the real ``shiftKey`` state
// for range-select AND so keyboard activation (Space on the focused
// checkbox) toggles selection via the same code path — the browser
// synthesises a click on <button> for Space, so one handler covers
// mouse + keyboard cleanly.
const handleSelectClick = (e: React.MouseEvent) => {
e.stopPropagation();
onSelectClick(e);
};
return (
<div
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
session.is_active
? "border-success/30 bg-success/[0.03]"
: "border-border"
}`}
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${containerClasses}`}
>
<div
className="flex cursor-pointer items-start gap-3 p-3 transition-colors hover:bg-secondary/30"
onClick={onToggle}
>
<span className="flex shrink-0 items-center pt-0.5">
<Checkbox
checked={isSelected}
onClick={handleSelectClick}
aria-label={t.sessions.selectSession}
/>
</span>
<div className={`shrink-0 pt-0.5 ${sourceInfo.color}`}>
<SourceIcon className="h-4 w-4" />
</div>
@ -606,6 +626,30 @@ export default function SessionsPage() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
const [view, setView] = useState<SessionsView>("overview");
// Count of empty (no-message, ended, non-archived) sessions across the
// entire DB, populated by /api/sessions/empty/count. Used to:
// • hide the "Delete empty" button when there's nothing to clean up
// • show "(N)" alongside the label
// • surface the count in the confirm dialog body
// Refreshed on mount, after single-session deletes, and after the bulk
// delete itself — none of those code paths can update the global empty
// count from local state alone (per-page list != global DB count).
const [emptyCount, setEmptyCount] = useState(0);
const [deleteEmptyOpen, setDeleteEmptyOpen] = useState(false);
const [deletingEmpty, setDeletingEmpty] = useState(false);
// Bulk-select-then-delete state. ``selectedIds`` is a Set so per-row
// checkbox toggles and ``has()`` lookups are O(1); we wrap mutations
// in a fresh Set so React notices the change (mutating in place
// wouldn't trigger a re-render).
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Index of the last row whose checkbox was clicked WITHOUT shift,
// resolved against the currently visible (post-search) ``filtered``
// list. Used as the anchor for shift-click range select — matches the
// Gmail / Notion / file-explorer convention. ``null`` means "no
// anchor yet", in which case shift-click degrades to a plain toggle.
const lastClickedIndexRef = useRef<number | null>(null);
const [deleteSelectedOpen, setDeleteSelectedOpen] = useState(false);
const [deletingSelected, setDeletingSelected] = useState(false);
const [stats, setStats] = useState<SessionStoreStats | null>(null);
const [pruneOpen, setPruneOpen] = useState(false);
const [pruneDays, setPruneDays] = useState("90");
@ -616,6 +660,18 @@ export default function SessionsPage() {
const { activeAction, actionStatus, dismissLog } = useSystemActions();
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
const refreshEmptyCount = useCallback(() => {
api
.getEmptySessionsCount()
.then((r) => setEmptyCount(r.count))
.catch(() => {});
}, []);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
lastClickedIndexRef.current = null;
}, []);
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
@ -673,7 +729,8 @@ export default function SessionsPage() {
useEffect(() => {
loadSessions(page);
}, [loadSessions, page]);
refreshEmptyCount();
}, [loadSessions, page, refreshEmptyCount]);
useEffect(() => {
const loadOverview = () => {
@ -696,6 +753,36 @@ export default function SessionsPage() {
if (el) el.scrollTop = el.scrollHeight;
}, [actionStatus?.lines]);
// Wrapped setters that ALSO clear the bulk selection. The user's
// mental model is "I'm selecting what I can see" — carrying a
// selection across a page change, search input, or view switch
// would arm invisible rows for deletion, which is the exact footgun
// the confirm dialog can't catch. Doing this at the call sites
// instead of in a ``useEffect`` keeps us out of the
// react-hooks/set-state-in-effect lint trap and the cascading
// re-render it warns about.
const goToPage = useCallback(
(p: number) => {
setPage(p);
clearSelection();
},
[clearSelection],
);
const updateSearch = useCallback(
(value: string) => {
setSearch(value);
clearSelection();
},
[clearSelection],
);
const switchView = useCallback(
(next: SessionsView) => {
setView(next);
clearSelection();
},
[clearSelection],
);
// Debounced FTS search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
@ -728,6 +815,18 @@ export default function SessionsPage() {
setSessions((prev) => prev.filter((s) => s.id !== id));
setTotal((prev) => prev - 1);
if (expandedId === id) setExpandedId(null);
// Drop the deleted ID from any active bulk-select set — it
// can't bulk-delete a row that's already gone.
setSelectedIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
// A single-session delete might have been an empty one — re-fetch
// the global empty count so the button hides itself / its badge
// ticks down without waiting for the next page navigation.
refreshEmptyCount();
showToast(t.sessions.sessionDeleted, "success");
loadStats();
} catch {
@ -737,6 +836,7 @@ export default function SessionsPage() {
},
[
expandedId,
refreshEmptyCount,
showToast,
loadStats,
t.sessions.sessionDeleted,
@ -745,6 +845,140 @@ export default function SessionsPage() {
),
});
/** Toggle one row's selection. When ``event.shiftKey`` is true AND we
* have a previous anchor, every row between the anchor and the
* current index (inclusive) is set to the current row's NEW state —
* matches Gmail/Notion/file-explorer semantics. ``visibleList`` must
* be the currently rendered list (post-search), since indices are
* resolved against what the user is actually looking at.
*/
const handleSelectClick = useCallback(
(event: React.MouseEvent, index: number, visibleList: SessionInfo[]) => {
const id = visibleList[index]?.id;
if (!id) return;
setSelectedIds((prev) => {
const next = new Set(prev);
const wasSelected = next.has(id);
const willSelect = !wasSelected;
const anchor = lastClickedIndexRef.current;
// Shift-click extends the selection from the anchor to here.
// Skip if there's no anchor or the anchor is outside the
// visible list — in those cases fall through to a plain toggle
// (the click also resets the anchor below).
if (event.shiftKey && anchor !== null && anchor < visibleList.length) {
const [lo, hi] =
anchor <= index ? [anchor, index] : [index, anchor];
for (let i = lo; i <= hi; i++) {
const rowId = visibleList[i]?.id;
if (!rowId) continue;
if (willSelect) next.add(rowId);
else next.delete(rowId);
}
} else if (willSelect) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
// Always update the anchor to the most recent click — even when
// it was a shift-click that extended a range, the user's next
// shift-click should anchor from here, not from two steps back.
lastClickedIndexRef.current = index;
},
[],
);
const selectAllOnPage = useCallback((visibleList: SessionInfo[]) => {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const s of visibleList) next.add(s.id);
return next;
});
}, []);
const handleDeleteSelected = useCallback(async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) {
setDeleteSelectedOpen(false);
return;
}
setDeletingSelected(true);
try {
const resp = await api.bulkDeleteSessions(ids);
showToast(
t.sessions.selectedSessionsDeleted.replace(
"{count}",
String(resp.deleted),
),
"success",
);
setDeleteSelectedOpen(false);
// Drop deleted rows out of the visible list immediately rather
// than waiting for the reload. The reload still runs so total /
// pagination stays correct, and so any rows the reload pulls in
// from later pages render in place.
const deletedSet = new Set(ids);
setSessions((prev) => prev.filter((s) => !deletedSet.has(s.id)));
setTotal((prev) => Math.max(0, prev - resp.deleted));
if (expandedId && deletedSet.has(expandedId)) setExpandedId(null);
clearSelection();
loadSessions(page);
refreshEmptyCount();
} catch {
showToast(t.sessions.failedToDeleteSelected, "error");
} finally {
setDeletingSelected(false);
}
}, [
clearSelection,
expandedId,
loadSessions,
page,
refreshEmptyCount,
selectedIds,
showToast,
t.sessions.failedToDeleteSelected,
t.sessions.selectedSessionsDeleted,
]);
const handleDeleteEmpty = useCallback(async () => {
setDeletingEmpty(true);
try {
const resp = await api.deleteEmptySessions();
// Show count in the toast so users get confirmation of the actual
// number removed (which may differ slightly from `emptyCount` if a
// session entered/left the "empty" set between the count fetch and
// the delete — e.g. an active session just ended without sending
// any messages).
showToast(
t.sessions.emptySessionsDeleted.replace(
"{count}",
String(resp.deleted),
),
"success",
);
setDeleteEmptyOpen(false);
// Reload the current page so any newly-vanished empty sessions
// drop out of the visible list, and re-fetch the empty count so
// the button hides itself.
loadSessions(page);
refreshEmptyCount();
} catch {
showToast(t.sessions.failedToDeleteEmpty, "error");
} finally {
setDeletingEmpty(false);
}
}, [
loadSessions,
page,
refreshEmptyCount,
showToast,
t.sessions.emptySessionsDeleted,
t.sessions.failedToDeleteEmpty,
]);
const handleRename = useCallback(
async (id: string, title: string) => {
try {
@ -898,6 +1132,33 @@ export default function SessionsPage() {
loading={sessionDelete.isDeleting}
/>
<DeleteConfirmDialog
open={deleteEmptyOpen}
onCancel={() => setDeleteEmptyOpen(false)}
onConfirm={handleDeleteEmpty}
title={t.sessions.deleteEmptyConfirmTitle}
description={t.sessions.deleteEmptyConfirmMessage.replace(
"{count}",
String(emptyCount),
)}
loading={deletingEmpty}
/>
<DeleteConfirmDialog
open={deleteSelectedOpen}
onCancel={() => setDeleteSelectedOpen(false)}
onConfirm={handleDeleteSelected}
title={t.sessions.deleteSelectedConfirmTitle.replace(
"{count}",
String(selectedIds.size),
)}
description={t.sessions.deleteSelectedConfirmMessage.replace(
"{count}",
String(selectedIds.size),
)}
loading={deletingSelected}
/>
<Dialog
open={pruneOpen}
onOpenChange={(open) => {
@ -1084,7 +1345,7 @@ export default function SessionsPage() {
className="w-fit shrink-0"
size="md"
value={view}
onChange={setView}
onChange={switchView}
options={[
{ value: "overview", label: t.sessions.overview },
{ value: "list", label: t.sessions.history },
@ -1102,7 +1363,7 @@ export default function SessionsPage() {
<Input
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(e) => updateSearch(e.target.value)}
className="h-8 py-0 pr-7 pl-8 text-xs leading-none"
/>
{search && (
@ -1110,7 +1371,7 @@ export default function SessionsPage() {
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
onClick={() => updateSearch("")}
aria-label={t.common.clear}
>
<X />
@ -1118,6 +1379,23 @@ export default function SessionsPage() {
)}
</div>
)}
{showList && emptyCount > 0 && !isSearching && (
<Button
outlined
destructive
size="sm"
className="shrink-0"
onClick={() => setDeleteEmptyOpen(true)}
aria-label={t.sessions.deleteEmpty}
title={t.sessions.deleteEmpty}
>
<Eraser className="h-3.5 w-3.5" />
<span className="font-mondwest normal-case text-xs">
{t.sessions.deleteEmpty} ({emptyCount})
</span>
</Button>
)}
</div>
{showPagination && (
@ -1126,12 +1404,77 @@ export default function SessionsPage() {
className="shrink-0 sm:ml-auto"
page={page}
total={total}
onPageChange={setPage}
onPageChange={goToPage}
/>
)}
</div>
) : null}
{showList && selectedIds.size > 0 && (
<div
className="flex flex-wrap items-center gap-2 border border-primary/30 bg-primary/[0.06] px-3 py-2"
role="region"
aria-label={t.sessions.selectedCount.replace(
"{count}",
String(selectedIds.size),
)}
>
<span className="font-mondwest normal-case text-xs text-primary tabular-nums">
{t.sessions.selectedCount.replace(
"{count}",
String(selectedIds.size),
)}
</span>
{filtered.some((s) => !selectedIds.has(s.id)) && (
<Button
ghost
size="sm"
onClick={() => selectAllOnPage(filtered)}
aria-label={t.sessions.selectAllOnPage}
title={t.sessions.selectAllOnPage}
>
<span className="font-mondwest normal-case text-xs">
{t.sessions.selectAllOnPage}
</span>
</Button>
)}
<Button
ghost
size="sm"
onClick={clearSelection}
aria-label={t.sessions.clearSelection}
title={t.sessions.clearSelection}
>
<span className="font-mondwest normal-case text-xs">
{t.sessions.clearSelection}
</span>
</Button>
<Button
outlined
destructive
size="sm"
className="ml-auto"
onClick={() => setDeleteSelectedOpen(true)}
aria-label={t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
title={t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="font-mondwest normal-case text-xs">
{t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
</span>
</Button>
</div>
)}
{showList ? (
filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
@ -1148,16 +1491,20 @@ export default function SessionsPage() {
) : (
<>
<div className="flex min-w-0 flex-col gap-1.5">
{filtered.map((s) => (
{filtered.map((s, index) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
isSelected={selectedIds.has(s.id)}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onSelectClick={(event) =>
handleSelectClick(event, index, filtered)
}
onDelete={() => sessionDelete.requestDelete(s.id)}
onRename={handleRename}
onExport={handleExport}
@ -1170,7 +1517,7 @@ export default function SessionsPage() {
<SessionsPagination
page={page}
total={total}
onPageChange={setPage}
onPageChange={goToPage}
/>
)}
</>
@ -1238,6 +1585,20 @@ export default function SessionsPage() {
);
}
interface SessionRowProps {
isExpanded: boolean;
isSelected: boolean;
onDelete: () => void;
onExport: (id: string) => void;
onRename: (id: string, title: string) => Promise<void>;
onSelectClick: (event: React.MouseEvent) => void;
onToggle: () => void;
resumeInChatEnabled: boolean;
searchQuery?: string;
session: SessionInfo;
snippet?: string;
}
interface SessionsPaginationProps {
className?: string;
compact?: boolean;

View File

@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import {
Activity,
Brain,
@ -21,12 +22,12 @@ import {
} from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
@ -236,16 +237,9 @@ export default function SystemPage() {
};
// ── Memory ─────────────────────────────────────────────────────────
const setMemoryProvider = async (provider: string) => {
try {
await api.setMemoryProvider(provider);
showToast(`Memory provider: ${provider || "built-in only"}`, "success");
loadAll();
} catch (e) {
showToast(`Failed to set provider: ${e}`, "error");
}
};
// Memory provider selection lives on the /plugins page now (see the
// read-only display + link below); the dropdown was intentionally
// dropped from this card during the admin-panel refresh.
const memoryReset = useConfirmDelete({
onDelete: useCallback(
async (target: string) => {
@ -748,26 +742,22 @@ export default function SystemPage() {
</H2>
<Card>
<CardContent className="flex flex-col gap-4 py-4">
<div className="grid gap-2 max-w-sm">
<Label htmlFor="mem-provider">External provider</Label>
<Select
id="mem-provider"
value={memory?.active || ""}
onValueChange={setMemoryProvider}
>
<SelectOption value="">Built-in only</SelectOption>
{(memory?.providers ?? []).map((p) => (
<SelectOption key={p.name} value={p.name}>
{p.name}
{p.configured ? " (configured)" : ""}
</SelectOption>
))}
</Select>
<p className="text-xs text-muted-foreground">
Set up a new provider's credentials with{" "}
<span className="font-mono">hermes memory setup</span>.
</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>
External provider:{" "}
<span className="font-mono text-foreground">
{memory?.active || "built-in only"}
</span>
</span>
<Link to="/plugins" className="underline">
Change in Plugins
</Link>
<span className="ml-auto">
New credentials:{" "}
<span className="font-mono">hermes memory setup</span>
</span>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-3">
<span className="text-xs text-muted-foreground">
Built-in files MEMORY.md:{" "}

View File

@ -19,6 +19,7 @@ import type {
ThemeLayoutVariant,
ThemeListEntry,
ThemePalette,
ThemeSeriesColors,
ThemeTypography,
} from "./types";
import { api } from "@/lib/api";
@ -27,6 +28,21 @@ import { api } from "@/lib/api";
* a visible flash of the default palette on theme-overridden installs. */
const STORAGE_KEY = "hermes-dashboard-theme";
/** Renames of built-in theme keys we've shipped previously. Without this,
* users who saved one of the old names in localStorage (or had it
* persisted server-side) would silently fall back to `defaultTheme`
* because the lookup in `resolveTheme` no longer finds the stale key.
* Keep entries here until enough release cycles have passed that we can
* reasonably assume nobody still has the old value persisted. */
const THEME_NAME_ALIASES: Record<string, string> = {
// Renamed during the LENS_5I port + Nous-blue rebrand.
"lens-5i": "nous-blue",
};
function migrateThemeName(name: string): string {
return THEME_NAME_ALIASES[name] ?? name;
}
/** Tracks fontUrls we've already injected so multiple theme switches don't
* pile up <link> tags. Keyed by URL. */
const INJECTED_FONT_URLS = new Set<string>();
@ -126,6 +142,30 @@ function overrideVars(
return out;
}
/** Map data-series accents to their CSS vars. Themes omit either field to
* inherit the `:root` default from `index.css`; when omitted we also
* proactively clear any leftover value from a previous theme so switches
* don't carry stale colors. */
const SERIES_KEY_TO_VAR: Record<keyof ThemeSeriesColors, string> = {
inputTokenAccent: "--series-input-token",
outputTokenAccent: "--series-output-token",
};
const ALL_SERIES_VARS = Object.values(SERIES_KEY_TO_VAR);
function seriesColorVars(
series: ThemeSeriesColors | undefined,
): Record<string, string> {
if (!series) return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(series)) {
if (!value) continue;
const cssVar = SERIES_KEY_TO_VAR[key as keyof ThemeSeriesColors];
if (cssVar) out[cssVar] = value;
}
return out;
}
// ---------------------------------------------------------------------------
// Asset + component-style + layout variant vars
// ---------------------------------------------------------------------------
@ -268,6 +308,12 @@ function applyTheme(theme: DashboardTheme) {
for (const cssVar of ALL_OVERRIDE_VARS) {
root.style.removeProperty(cssVar);
}
// Same clear-then-set for series colors so a theme that defines them
// (e.g. Nous Blue) doesn't leave its values behind when the user
// switches to a theme that inherits the `:root` defaults.
for (const cssVar of ALL_SERIES_VARS) {
root.style.removeProperty(cssVar);
}
// Clear dynamic (asset/component) vars from the previous theme so the
// new one starts clean — otherwise stale notched clip-paths, hero URLs,
// etc. would bleed across theme switches.
@ -287,6 +333,7 @@ function applyTheme(theme: DashboardTheme) {
...typographyVars(theme.typography),
...layoutVars(theme.layout),
...overrideVars(theme.colorOverrides),
...seriesColorVars(theme.seriesColors),
...assetMap,
...componentMap,
};
@ -313,7 +360,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
/** Name of the currently active theme (built-in id or user YAML name). */
const [themeName, setThemeName] = useState<string>(() => {
if (typeof window === "undefined") return "default";
return window.localStorage.getItem(STORAGE_KEY) ?? "default";
const stored = window.localStorage.getItem(STORAGE_KEY) ?? "default";
const migrated = migrateThemeName(stored);
// Write the migrated name back so future reads converge on the new
// key and we eventually retire the alias entry.
if (migrated !== stored) {
window.localStorage.setItem(STORAGE_KEY, migrated);
}
return migrated;
});
/** All selectable themes (shown in the picker). Starts with just the
@ -377,9 +431,18 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}
if (Object.keys(defs).length > 0) setUserThemeDefs(defs);
}
if (resp.active && resp.active !== themeName) {
setThemeName(resp.active);
window.localStorage.setItem(STORAGE_KEY, resp.active);
if (resp.active) {
const migratedActive = migrateThemeName(resp.active);
if (migratedActive !== themeName) {
setThemeName(migratedActive);
window.localStorage.setItem(STORAGE_KEY, migratedActive);
}
// If the server is still persisting the stale key, push the
// migrated value back so it converges too — otherwise every
// future page load would re-trigger this branch.
if (migratedActive !== resp.active) {
api.setTheme(migratedActive).catch(() => {});
}
}
})
.catch(() => {});

View File

@ -184,6 +184,100 @@ export const roseTheme: DashboardTheme = {
},
};
/**
* Nous Blue — the inverted "light mode" Hermes look, ported from the
* LENS_5I overlay preset in `@nous-research/ui`.
*
* Unlike the other built-ins (which paint dark color directly on the
* canvas), this theme relies on `<Backdrop />`'s foreground inversion
* layer: an opaque white sheet at z-200 with `mix-blend-mode: difference`
* that flips the entire stack below it. Authoring colors stay dark
* (`#170d02` brown background, `#FFAC02` orange midground), and the
* inversion converts them to their visual complements at paint time —
* the orange midground reads as #0053FD Nous-blue on screen, against a
* cream `#E8F2FD` canvas.
*
* Note on bg blend mode: the DS Lens uses `multiply` for LENS_5I because
* nousnet-web's <body> is white; hermes-agent's App root is `bg-black`,
* so we leave the bg layer's blend mode at the `difference` default —
* `difference(#170d02, #000)` passes the bg through unchanged, and the
* subsequent FG-difference layer then inverts it to cream. Using
* `multiply` here would collapse the bg to pure black against the
* `bg-black` root and produce a plain-white canvas instead of the
* intended cream-blue.
*
* Source of truth for the palette: `design-language/src/ui/components/
* overlays/lens.ts` (LENS_5I export).
*/
export const nousBlueTheme: DashboardTheme = {
name: "nous-blue",
label: "Nous Blue",
description: "Light mode — vivid Nous-blue accents on cream canvas",
palette: {
background: { hex: "#170d02", alpha: 1 },
midground: { hex: "#FFAC02", alpha: 1 },
foreground: { hex: "#FFFFFF", alpha: 1 },
// Same warm-amber as nousnet-web's overlay glow; after the FG
// inversion it reads as a cool ultraviolet vignette in the top-left.
warmGlow: "rgba(255, 172, 2, 0.18)",
// Noise sits above the FG inversion and is NOT flipped, so a softer
// multiplier keeps it from speckling over the bright post-inversion
// canvas.
noiseOpacity: 0.4,
},
typography: DEFAULT_TYPOGRAPHY,
layout: DEFAULT_LAYOUT,
// Inverted page: the embedded terminal is below the FG layer too, so
// a `#000000` source paints as visual white — i.e. a proper light-mode
// terminal pane. xterm picks lighter palette colors against the "black"
// canvas, which then read as dark text on screen post-inversion.
terminalBackground: "#000000",
componentStyles: {
backdrop: {
// Lower than LENS_5I.Lens.fillerOpacity (0.06). The filler texture
// gets amplified post-inversion: small variations against the deep
// `#170d02` source bg are barely visible, but those same variations
// against the bright `#E8F2FD` post-inversion canvas read as a
// heavy cloud/marble pattern — especially on near-empty pages
// (loading spinners, blank states). 0.02 keeps subtle grain
// without overwhelming the canvas.
fillerOpacity: "0.02",
},
},
// Pre-invert absolute-hex tokens so they read as their familiar colors
// through the FG difference layer. e.g. source #04D3C9 (cyan) is what
// gets painted, and `255 - channel` flips it to #FB2C36 (red) on screen.
// Without these, the default destructive/success/warning tokens would
// appear as their unintuitive complements.
colorOverrides: {
destructive: "#04d3c9",
destructiveForeground: "#000000",
success: "#b5217f",
warning: "#0042c7",
},
// Pre-inverted data-series accents for the Analytics/Models token
// charts. The defaults (#ffe6cb cream + #34d399 emerald) would render
// through the FG difference layer as dark navy + hot-coral on the
// bright Nous-blue canvas — the coral is the "red" users see for
// Output values without these overrides. Source → on-screen:
// Input: #ffe6cb → #001934 (dark navy) ← unchanged
// Output: #ffac02 → #0053fd (vivid Nous-blue) ← brand accent
// Input keeps the cream source so it stays a neutral, low-contrast
// dark-blue against the cream canvas; output paints as the brand
// Nous-blue so the "primary" series in token-flow charts reads as
// the highlight color, matching the rest of the inverted UI chrome.
seriesColors: {
inputTokenAccent: "#ffe6cb",
outputTokenAccent: "#ffac02",
},
// Explicit picker swatch — the raw palette hex (`#170d02`, `#FFAC02`,
// amber rgba) doesn't reflect what users see after the FG inversion,
// so we paint the post-inversion visual triplet directly:
// white → vivid Nous-blue → cream/light-blue
// matching the actual on-screen rendering of the theme.
swatchColors: ["#FFFFFF", "#0053FD", "#E8F2FD"],
};
/**
* Same look as ``defaultTheme`` but with a larger root font size, looser
* line-height, and ``spacious`` density so every rem-based size in the
@ -208,6 +302,7 @@ export const defaultLargeTheme: DashboardTheme = {
export const BUILTIN_THEMES: Record<string, DashboardTheme> = {
default: defaultTheme,
"default-large": defaultLargeTheme,
"nous-blue": nousBlueTheme,
midnight: midnightTheme,
ember: emberTheme,
mono: monoTheme,

View File

@ -119,6 +119,25 @@ export interface ThemeComponentStyles {
page?: Record<string, string>;
}
/** Data-series accent colors for chart + table visualisations (Analytics,
* Models, etc.). Themes provide hex strings; the provider emits them as
* `--series-input-token` / `--series-output-token` CSS vars consumed
* inline by pages that render input-vs-output token flows. Themes can
* omit either field to inherit the default token defined in
* `index.css` (Hermes-teal `#ffe6cb` for input, `#34d399` for output).
*
* Inverted-lens themes (e.g. Nous Blue) must pre-invert these hex
* values so they read as their intended visual color after the FG
* difference layer flips them (`out = 255 channel`). E.g. to make
* output paint as Nous-blue `#0053FD` on screen, set
* `outputTokenAccent: "#FFAC02"` — the difference math reverses it. */
export interface ThemeSeriesColors {
/** Input-tokens series accent (Analytics chart bars + table values). */
inputTokenAccent?: string;
/** Output-tokens series accent. */
outputTokenAccent?: string;
}
/** Optional hex overrides keyed by shadcn-compat token name (without the
* `--color-` prefix). Any key set here wins over the DS cascade. */
export interface ThemeColorOverrides {
@ -162,6 +181,15 @@ export interface DashboardTheme {
/** Per-component CSS-var overrides. See `ThemeComponentStyles`. */
componentStyles?: ThemeComponentStyles;
colorOverrides?: ThemeColorOverrides;
/** Data-series accent colors for Analytics/Models token charts.
* See `ThemeSeriesColors` for inversion-aware values. */
seriesColors?: ThemeSeriesColors;
/** Explicit 3-color swatch override for the theme picker. Use when the
* palette's raw hex values don't reflect what users see on screen —
* e.g. inverted "lens" themes whose foreground-difference layer flips
* the authored colors to their visual complements. Order matches the
* default swatch cells: [background, midground, warmGlow]. */
swatchColors?: [string, string, string];
/** Background color for the embedded terminal pane (xterm.js).
* Hex string. Defaults to `"#000000"` when absent. */
terminalBackground?: string;