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:
@ -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`
|
||||
|
||||
@ -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"},
|
||||
|
||||
172
hermes_state.py
172
hermes_state.py
@ -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,
|
||||
|
||||
@ -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"}
|
||||
@ -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": {
|
||||
24
tests/fixtures/plugins/example-dashboard/dashboard/plugin_api.py
vendored
Normal file
24
tests/fixtures/plugins/example-dashboard/dashboard/plugin_api.py
vendored
Normal 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"}
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
# =========================================================================
|
||||
|
||||
@ -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\")",
|
||||
|
||||
@ -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>
|
||||
)}
|
||||
|
||||
273
web/src/components/ScheduleBuilder.tsx
Normal file
273
web/src/components/ScheduleBuilder.tsx
Normal 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;
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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 ジョブが設定されていません。上で作成してください。",
|
||||
|
||||
@ -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 작업이 없습니다. 위에서 하나 만드세요.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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-задачи не настроены. Создайте задачу выше.",
|
||||
|
||||
@ -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.",
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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-завдань не налаштовано. Створіть одне вище.",
|
||||
|
||||
@ -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: "尚未設定排程任務。請於上方建立。",
|
||||
|
||||
@ -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: "暂无定时任务。在上方创建一个。",
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
382
web/src/lib/schedule.ts
Normal 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`;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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:{" "}
|
||||
|
||||
@ -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(() => {});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user