feat(dashboard): nous-blue theme, bulk sessions, schedule picker (#37383)

* feat(dashboard): nous-blue theme, bulk sessions, schedule picker

Batch of related dashboard improvements gathered on
austin/fix/dashboard-changes:

* Nous Blue theme — faithful port of the LENS_5I overlay system onto
  the existing DashboardTheme. Lifts the foreground inversion layer to
  z-index 200 to fix the long-standing hover / loading visual artifact,
  adds an explicit swatchColors slot so the theme picker shows the
  post-inversion preview, and migrates the legacy "lens-5i" theme key
  from localStorage / API to "nous-blue" on first read.
* Theme-aware series colors: new --series-input-token /
  --series-output-token CSS vars consumed by Analytics + Models
  charts; ToolCall + ModelInfoCard switched to semantic
  --color-success for diff lines and the Tools capability badge.
* Analytics + Models headers: consolidate period selector + refresh
  next to the page title and drop the redundant period badge.
* Bulk session management — "Delete empty (N)" button + per-row
  checkboxes with shift-click range select and a bulk-delete action
  bar. Backed by SessionDB.delete_sessions() /
  delete_empty_sessions() plus POST /api/sessions/bulk-delete and
  DELETE /api/sessions/empty (registered before the templated
  /api/sessions/{session_id} family so they don't get shadowed).
  Hard cap of 500 IDs per bulk request. Full pytest coverage.
* Cron page — human-readable schedule picker (every-interval / daily
  / weekly / monthly / once / custom) replaces the raw cron
  expression input; the job list now renders "Weekly on Mon, Wed,
  Fri at 14:30" instead of "30 14 * * 1,3,5". English-only ordinals
  for monthly schedules so non-English locales don't get incorrect
  suffixes.
* example-dashboard plugin moved from plugins/ to tests/fixtures/ so
  stock installs no longer ship the demo. Tests install it
  dynamically via a pytest fixture that also reorders the FastAPI
  routes.
* i18n: 40+ new keys for the bulk-select UI and schedule
  picker/describer translated across all 16 locales.

Co-authored-by: Cursor <cursoragent@cursor.com>

* refactor(dashboard): dedupe memory provider picker

The memory provider <Select> lived on both /system and /plugins,
writing the same config.yaml field through two different endpoints
with no cross-page refresh. Remove the picker from /system in favor
of a read-only status row + link to /plugins, where it pairs with
the context-engine picker under "Plugin providers".

/system retains the destructive admin controls (file sizes, Reset
MEMORY.md / USER.md / all). The api.setMemoryProvider client and
PUT /api/memory/provider backend endpoint are left in place for
CLI / script callers.

Co-authored-by: Cursor <cursoragent@cursor.com>

* docs(dashboard): address Copilot review on PR #37383

- Backdrop layer-stack comment claimed LENS_5I-style themes override
  --component-backdrop-bg-blend-mode to multiply, but our only
  LENS_5I-style theme (nous-blue) keeps the default difference.
  Reword to describe what the code actually does and present the
  var as a forward-looking extension hook.
- /api/sessions/bulk-delete docstring promised the response would
  echo back the list of deleted IDs, but the implementation only
  returns {ok, deleted}. Tighten the docstring to match the wire
  format; the client already knows what it asked to delete, so the
  IDs aren't needed.

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(dashboard): address copilot review on cron describe + bulk-select checkbox

- schedule.ts: restrict `describeCronExpression` to strictly 5-field cron
  expressions. The backend `parse_schedule` also accepts the 6-field
  `min hour dom month dow year` form, and humanising those by
  destructuring only the first five fields would silently drop the year
  (e.g. ``0 9 * * * 2099`` rendered as "Daily at 09:00"). 6+ field
  expressions now fall through to the raw-string fallback so the user
  sees what's actually scheduled.

- SessionsPage.tsx (SessionRow): wire the bulk-select Checkbox's
  ``onClick`` directly instead of attaching it to a parent ``<span>``
  with a no-op ``onCheckedChange``. Radix forwards onClick to the
  underlying ``<button role=checkbox>``, so the same handler now drives
  both mouse clicks (preserving shift-key state for range select) and
  keyboard activation (Space on the focused checkbox, which the browser
  synthesises as a click on the <button>). Improves a11y / keyboard UX
  without changing the controlled-selection model.

- SessionsPage.tsx: also extend ``SessionRowProps`` with the new
  ``onRename`` / ``onExport`` props introduced on main so the row's
  destructured prop types resolve after the merge.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Austin Pickett
2026-06-02 12:37:40 -04:00
committed by GitHub
co-authored by Cursor
parent a6b6afdff4
commit 6d14a24b79
41 changed files with 3299 additions and 213 deletions
+52 -8
View File
@@ -11,11 +11,27 @@ import fillerBgUrl from "@nous-research/ui/assets/filler-bg0.webp";
* and the warm vignette both read theme-switchable CSS custom properties so
* `ThemeProvider` can repaint the stack without remounting.
*
* z-1 bg = `var(--background-base)`, mix-blend-mode: difference
* z-1 bg = `var(--background-base)`, mix-blend-mode driven by
* `--component-backdrop-bg-blend-mode` (default `difference`).
* Both LENS_0-style dark themes and the LENS_5I-style Nous Blue
* light theme keep `difference` here — the canvas is flipped by
* the z-200 FG inversion layer, not by changing this blend mode.
* The CSS var is exposed as a hook so future presets can override
* it (e.g. `multiply` to paint the bg as-is before inversion)
* without touching this component.
* z-2 bundled filler-bg WebP, inverted, opacity 0.033, difference
* z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten
* z-101 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
* color-dodge) — gated on GPU tier
* z-200 FG inversion = `var(--foreground)` (opaque white in LENS_5I,
* alpha-0 in LENS_0), mix-blend-mode: difference. This is the
* layer that flips the dashboard into "light mode" for inverted
* themes; for normal dark themes its alpha is 0 so it's a no-op.
* Deliberately placed above every UI overlay z-index (modals,
* tooltips, and dropUp dropdowns all sit at z-[100]) so portaled
* elements get inverted along with the rest of the page instead
* of painting with pre-inversion colors on top of the lens.
* z-201 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
* color-dodge) — gated on GPU tier. Sits above the inversion
* layer by design so the grain is not flipped.
*
* `useGpuTier` returns 0 when WebGL is unavailable, the renderer is a
* software rasterizer (SwiftShader/llvmpipe), or the user has
@@ -31,10 +47,13 @@ export function Backdrop() {
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[1]"
style={{
backgroundColor: "var(--background-base)",
mixBlendMode: "difference",
}}
style={
{
backgroundColor: "var(--background-base)",
mixBlendMode:
"var(--component-backdrop-bg-blend-mode, difference)",
} as unknown as React.CSSProperties
}
/>
<div
@@ -75,10 +94,35 @@ export function Backdrop() {
}}
/>
{/* Foreground inversion layer. Source-of-truth: LENS_5I.Lens.fgOpacity
+ fgBlend: 'difference' in `design-language/src/ui/components/
overlays/lens.ts`. With `--foreground-alpha: 0` (LENS_0 dark default)
the layer is fully transparent and contributes nothing; with
alpha 1 + opaque white it inverts the entire stack below it,
producing the LENS_5I "light mode" look without altering any
downstream component code.
z-200 (not 100) so it sits above every portaled UI overlay —
sidebar tooltips, dropUp dropdowns, and modal dialogs all use
z-[100], which is what the DS Lens picks too; portals append
at the end of <body>, so equal z-index + later DOM order means
they'd paint on top of the inversion and skip the flip. Inlined
z-index for the same reason the DS does it — Tailwind's JIT
scan sometimes drops non-default z utilities. */}
<div
aria-hidden
className="pointer-events-none fixed inset-0"
style={{
backgroundColor: "var(--foreground)",
mixBlendMode: "difference",
zIndex: 200,
}}
/>
{gpuTier > 0 && (
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-[101]"
className="pointer-events-none fixed inset-0 z-[201]"
style={{
backgroundImage:
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E\")",
+1 -1
View File
@@ -86,7 +86,7 @@ export function ModelInfoCard({
{hasCaps && (
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
{caps.supports_tools && (
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<span className="inline-flex items-center gap-1 bg-success/10 px-2 py-0.5 text-xs font-medium text-success">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}
+273
View File
@@ -0,0 +1,273 @@
import { useCallback } from "react";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Button } from "@nous-research/ui/ui/components/button";
import { useI18n } from "@/i18n";
import {
buildScheduleString,
DEFAULT_SCHEDULE_STATE,
type IntervalUnit,
type ScheduleBuilderState,
type ScheduleMode,
type Weekday,
WEEKDAY_INDEXES,
} from "@/lib/schedule";
/**
* Human-readable schedule picker for cron job create/edit flows.
*
* Replaces the raw "type a cron expression" input that lived inline in
* ``CronPage``. The picker still emits a single backend-compatible
* schedule string (see ``cron/jobs.py::parse_schedule``), but the user
* fills out shape-appropriate inputs (time picker, weekday toggles,
* datetime-local field) per mode.
*
* Architecture:
*
* - The component is fully controlled. Parent owns the
* ``ScheduleBuilderState`` and the derived schedule string (built
* via ``buildScheduleString`` in render).
* - Mode-specific state slots (``timeOfDay``, ``weekdays``, ...) are
* preserved across mode switches so flipping back to a previous mode
* doesn't erase the user's work.
* - The "Custom" mode is an escape hatch — surfacing it as a normal
* option (instead of hiding it behind an "advanced" toggle) keeps
* power-user workflows discoverable without making everyone scroll
* past it.
*/
export function ScheduleBuilder({ onChange, value }: ScheduleBuilderProps) {
const { t } = useI18n();
const cronStrings = t.cron;
const modeStrings = cronStrings.scheduleModes;
const update = useCallback(
(patch: Partial<ScheduleBuilderState>) => {
onChange({ ...value, ...patch });
},
[onChange, value],
);
const toggleWeekday = useCallback(
(day: Weekday) => {
const present = value.weekdays.includes(day);
update({
weekdays: present
? value.weekdays.filter((d) => d !== day)
: [...value.weekdays, day],
});
},
[update, value.weekdays],
);
return (
<div className="grid gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-schedule-mode">
{cronStrings.scheduleMode ?? "Schedule"}
</Label>
<Select
id="cron-schedule-mode"
value={value.mode}
onValueChange={(v) => update({ mode: v as ScheduleMode })}
>
<SelectOption value="interval">{modeStrings.interval}</SelectOption>
<SelectOption value="daily">{modeStrings.daily}</SelectOption>
<SelectOption value="weekly">{modeStrings.weekly}</SelectOption>
<SelectOption value="monthly">{modeStrings.monthly}</SelectOption>
<SelectOption value="once">{modeStrings.once}</SelectOption>
<SelectOption value="custom">{modeStrings.custom}</SelectOption>
</Select>
</div>
{value.mode === "interval" && (
<div className="grid grid-cols-[1fr_1.4fr] gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-interval-value">
{modeStrings.intervalEvery}
</Label>
<Input
id="cron-interval-value"
type="number"
min={1}
max={9999}
value={String(value.intervalValue)}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
update({
intervalValue: Number.isFinite(n) && n > 0 ? n : 1,
});
}}
/>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-interval-unit">{modeStrings.intervalUnit}</Label>
<Select
id="cron-interval-unit"
value={value.intervalUnit}
onValueChange={(v) => update({ intervalUnit: v as IntervalUnit })}
>
<SelectOption value="minutes">
{modeStrings.unitMinutes}
</SelectOption>
<SelectOption value="hours">{modeStrings.unitHours}</SelectOption>
<SelectOption value="days">{modeStrings.unitDays}</SelectOption>
</Select>
</div>
</div>
)}
{value.mode === "daily" && (
<TimeOfDayField
id="cron-daily-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
)}
{value.mode === "weekly" && (
<>
<div className="grid gap-2">
<Label>{modeStrings.weekdays}</Label>
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={modeStrings.weekdays}
>
{WEEKDAY_INDEXES.map((d) => {
const isOn = value.weekdays.includes(d);
return (
<Button
key={d}
type="button"
size="sm"
outlined={!isOn}
aria-pressed={isOn}
onClick={() => toggleWeekday(d)}
className="min-w-[2.5rem] font-mono-ui text-xs uppercase"
>
{modeStrings.weekdaysShort[d]}
</Button>
);
})}
</div>
</div>
<TimeOfDayField
id="cron-weekly-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
</>
)}
{value.mode === "monthly" && (
<div className="grid grid-cols-[1fr_1fr] gap-3">
<div className="grid gap-2">
<Label htmlFor="cron-month-day">{modeStrings.dayOfMonth}</Label>
<Input
id="cron-month-day"
type="number"
min={1}
max={31}
value={String(value.dayOfMonth)}
onChange={(e) => {
const n = parseInt(e.target.value, 10);
update({
dayOfMonth:
Number.isFinite(n) && n >= 1 && n <= 31 ? n : 1,
});
}}
/>
</div>
<TimeOfDayField
id="cron-monthly-time"
label={modeStrings.timeOfDay}
value={value.timeOfDay}
onChange={(timeOfDay) => update({ timeOfDay })}
/>
</div>
)}
{value.mode === "once" && (
<div className="grid gap-2">
<Label htmlFor="cron-once-at">{modeStrings.onceAt}</Label>
{/* Native datetime-local — emits the exact "YYYY-MM-DDTHH:MM"
shape ``parse_schedule`` accepts on the backend. */}
<input
id="cron-once-at"
type="datetime-local"
className="flex h-9 w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
value={value.onceAt}
onChange={(e) => update({ onceAt: e.target.value })}
/>
</div>
)}
{value.mode === "custom" && (
<div className="grid gap-2">
<Label htmlFor="cron-custom-expr">{modeStrings.customLabel}</Label>
<Input
id="cron-custom-expr"
placeholder={modeStrings.customPlaceholder}
value={value.custom}
onChange={(e) => update({ custom: e.target.value })}
className="font-mono-ui"
/>
<p className="text-xs text-muted-foreground">
{modeStrings.customHint}
</p>
</div>
)}
{/* Inline preview of what we'll send to the backend. Helps users
eyeball the result before hitting Create, and keeps the
schedule grammar discoverable for the custom mode. */}
<p className="text-xs text-muted-foreground">
<span className="opacity-70">{modeStrings.preview}: </span>
<span className="font-mono-ui text-foreground">
{buildScheduleString(value) || modeStrings.previewEmpty}
</span>
</p>
</div>
);
}
function TimeOfDayField({
id,
label,
onChange,
value,
}: TimeOfDayFieldProps) {
return (
<div className="grid gap-2">
<Label htmlFor={id}>{label}</Label>
{/* Native time picker is the right tool for "HH:MM" — saves us
two separate hour/minute selects, respects user locale's
AM/PM preference, and round-trips with ``buildScheduleString``
without parsing. */}
<input
id={id}
type="time"
className="flex h-9 w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
value={value}
onChange={(e) => onChange(e.target.value)}
/>
</div>
);
}
export { DEFAULT_SCHEDULE_STATE };
interface ScheduleBuilderProps {
onChange: (state: ScheduleBuilderState) => void;
value: ScheduleBuilderState;
}
interface TimeOfDayFieldProps {
id: string;
label: string;
onChange: (value: string) => void;
value: string;
}
+14 -4
View File
@@ -208,15 +208,25 @@ function ThemeSwitcherOptions({
}
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
const { background, midground, warmGlow } = theme.palette;
// Inverted themes (Nous Blue / future lens themes) author their palette
// pre-inversion — `#FFAC02` reads as `#0053FD` blue once the foreground-
// difference layer flips the page. The picker can't replay that math
// cheaply, so themes opt-in to an explicit `swatchColors` triplet that
// mirrors the on-screen result. Falls back to the raw palette hexes for
// every other theme so existing dark-theme swatches are untouched.
const [c1, c2, c3] = theme.swatchColors ?? [
theme.palette.background.hex,
theme.palette.midground.hex,
theme.palette.warmGlow,
];
return (
<div
aria-hidden
className="flex h-4 w-9 shrink-0 overflow-hidden border border-current/20"
>
<span className="flex-1" style={{ background: background.hex }} />
<span className="flex-1" style={{ background: midground.hex }} />
<span className="flex-1" style={{ background: warmGlow }} />
<span className="flex-1" style={{ background: c1 }} />
<span className="flex-1" style={{ background: c2 }} />
<span className="flex-1" style={{ background: c3 }} />
</div>
);
}
+1 -1
View File
@@ -220,7 +220,7 @@ function colorizeDiff(diff: string): React.ReactNode {
function diffLineClass(line: string): string {
if (line.startsWith("+") && !line.startsWith("+++"))
return "text-emerald-500 dark:text-emerald-400";
return "text-success";
if (line.startsWith("-") && !line.startsWith("---"))
return "text-destructive";
if (line.startsWith("@@")) return "text-primary";
+51
View File
@@ -141,6 +141,22 @@ export const af: Translations = {
"Dit verwyder die gesprek en al sy boodskappe permanent. Dit kan nie ongedaan gemaak word nie.",
sessionDeleted: "Sessie geskrap",
failedToDelete: "Kon nie sessie skrap nie",
deleteEmpty: "Skrap leë",
deleteEmptyConfirmTitle: "Skrap leë sessies?",
deleteEmptyConfirmMessage:
"Dit verwyder permanent {count} sessies wat geen boodskappe het nie. Aktiewe en geargiveerde sessies word oorgeslaan. Dit kan nie ongedaan gemaak word nie.",
emptySessionsDeleted: "{count} leë sessies geskrap",
failedToDeleteEmpty: "Kon nie leë sessies skrap nie",
selectSession: "Kies sessie",
selectAllOnPage: "Kies alles op hierdie bladsy",
clearSelection: "Maak keuse skoon",
selectedCount: "{count} gekies",
deleteSelected: "Skrap {count}",
deleteSelectedConfirmTitle: "Skrap {count} sessies?",
deleteSelectedConfirmMessage:
"Dit verwyder {count} gekose sessies en al hul boodskappe permanent. Dit kan nie ongedaan gemaak word nie.",
selectedSessionsDeleted: "{count} sessies geskrap",
failedToDeleteSelected: "Kon nie gekose sessies skrap nie",
resumeInChat: "Hervat in Klets",
previousPage: "Vorige bladsy",
nextPage: "Volgende bladsy",
@@ -211,6 +227,41 @@ export const af: Translations = {
promptPlaceholder: "Wat moet die agent met elke uitvoering doen?",
schedule: "Skedule (cron-uitdrukking)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Skedule",
scheduleModes: {
interval: "Herhalende interval",
daily: "Daagliks",
weekly: "Weekliks",
monthly: "Maandeliks",
once: "Een keer",
custom: "Pasgemaak (cron-uitdrukking)",
intervalEvery: "Elke",
intervalUnit: "Eenheid",
unitMinutes: "minute",
unitHours: "ure",
unitDays: "dae",
timeOfDay: "Tyd van die dag",
weekdays: "Dae van die week",
weekdaysShort: ["Son", "Maa", "Din", "Woe", "Don", "Vry", "Sat"],
dayOfMonth: "Dag van die maand",
onceAt: "Hardloop op",
customLabel: "Cron-uitdrukking",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-uitdrukking met vyf velde (minuut, uur, dag, maand, weekdag).",
preview: "Word gestuur as",
previewEmpty: "(onvolledig)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Elke {n} min",
everyHours: "Elke {n} u",
everyDays: "Elke {n} d",
dailyAt: "Daagliks om {time}",
weeklyAt: "Weekliks op {days} om {time}",
monthlyAt: "Maandeliks op die {day} om {time}",
onceAt: "Een keer op {time}",
},
deliverTo: "Lewer aan",
scheduledJobs: "Geskeduleerde Take",
noJobs: "Geen cron-take gekonfigureer nie. Skep een hierbo.",
+51
View File
@@ -141,6 +141,22 @@ export const de: Translations = {
"Dies entfernt die Unterhaltung und alle Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.",
sessionDeleted: "Sitzung gelöscht",
failedToDelete: "Sitzung konnte nicht gelöscht werden",
deleteEmpty: "Leere löschen",
deleteEmptyConfirmTitle: "Leere Sitzungen löschen?",
deleteEmptyConfirmMessage:
"Dies entfernt dauerhaft {count} Sitzungen ohne Nachrichten. Aktive und archivierte Sitzungen werden übersprungen. Dies kann nicht rückgängig gemacht werden.",
emptySessionsDeleted: "{count} leere Sitzungen gelöscht",
failedToDeleteEmpty: "Leere Sitzungen konnten nicht gelöscht werden",
selectSession: "Sitzung auswählen",
selectAllOnPage: "Alle auf dieser Seite auswählen",
clearSelection: "Auswahl aufheben",
selectedCount: "{count} ausgewählt",
deleteSelected: "{count} löschen",
deleteSelectedConfirmTitle: "{count} Sitzungen löschen?",
deleteSelectedConfirmMessage:
"Dies entfernt {count} ausgewählte Sitzungen und alle zugehörigen Nachrichten dauerhaft. Dies kann nicht rückgängig gemacht werden.",
selectedSessionsDeleted: "{count} Sitzungen gelöscht",
failedToDeleteSelected: "Ausgewählte Sitzungen konnten nicht gelöscht werden",
resumeInChat: "Im Chat fortsetzen",
previousPage: "Vorherige Seite",
nextPage: "Nächste Seite",
@@ -211,6 +227,41 @@ export const de: Translations = {
promptPlaceholder: "Was soll der Agent bei jedem Lauf tun?",
schedule: "Zeitplan (Cron-Ausdruck)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Zeitplan",
scheduleModes: {
interval: "Wiederkehrendes Intervall",
daily: "Täglich",
weekly: "Wöchentlich",
monthly: "Monatlich",
once: "Einmalig",
custom: "Benutzerdefiniert (Cron-Ausdruck)",
intervalEvery: "Alle",
intervalUnit: "Einheit",
unitMinutes: "Minuten",
unitHours: "Stunden",
unitDays: "Tage",
timeOfDay: "Uhrzeit",
weekdays: "Wochentage",
weekdaysShort: ["So", "Mo", "Di", "Mi", "Do", "Fr", "Sa"],
dayOfMonth: "Tag des Monats",
onceAt: "Ausführen am",
customLabel: "Cron-Ausdruck",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-Ausdruck mit fünf Feldern (Minute, Stunde, Tag, Monat, Wochentag).",
preview: "Wird gesendet als",
previewEmpty: "(unvollständig)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Alle {n} Min.",
everyHours: "Alle {n} Std.",
everyDays: "Alle {n} Tage",
dailyAt: "Täglich um {time}",
weeklyAt: "Wöchentlich am {days} um {time}",
monthlyAt: "Monatlich am {day} um {time}",
onceAt: "Einmal am {time}",
},
deliverTo: "Zustellen an",
scheduledJobs: "Geplante Aufgaben",
noJobs: "Keine Cron-Aufgaben konfiguriert. Erstelle oben eine.",
+51
View File
@@ -141,6 +141,22 @@ export const en: Translations = {
"This permanently removes the conversation and all of its messages. This cannot be undone.",
sessionDeleted: "Session deleted",
failedToDelete: "Failed to delete session",
deleteEmpty: "Delete empty",
deleteEmptyConfirmTitle: "Delete empty sessions?",
deleteEmptyConfirmMessage:
"This permanently removes {count} sessions that have no messages. Active and archived sessions are skipped. This cannot be undone.",
emptySessionsDeleted: "{count} empty sessions deleted",
failedToDeleteEmpty: "Failed to delete empty sessions",
selectSession: "Select session",
selectAllOnPage: "Select all on this page",
clearSelection: "Clear selection",
selectedCount: "{count} selected",
deleteSelected: "Delete {count}",
deleteSelectedConfirmTitle: "Delete {count} sessions?",
deleteSelectedConfirmMessage:
"This permanently removes {count} selected sessions and all their messages. This cannot be undone.",
selectedSessionsDeleted: "{count} sessions deleted",
failedToDeleteSelected: "Failed to delete selected sessions",
resumeInChat: "Resume in Chat",
previousPage: "Previous page",
nextPage: "Next page",
@@ -211,6 +227,41 @@ export const en: Translations = {
promptPlaceholder: "What should the agent do on each run?",
schedule: "Schedule (cron expression)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Schedule",
scheduleModes: {
interval: "Every interval",
daily: "Daily",
weekly: "Weekly",
monthly: "Monthly",
once: "Once",
custom: "Custom (cron expression)",
intervalEvery: "Every",
intervalUnit: "Unit",
unitMinutes: "minutes",
unitHours: "hours",
unitDays: "days",
timeOfDay: "Time of day",
weekdays: "Days of week",
weekdaysShort: ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"],
dayOfMonth: "Day of month",
onceAt: "Run at",
customLabel: "Cron expression",
customPlaceholder: "0 9 * * *",
customHint:
"Five-field cron expression (minute, hour, day, month, weekday).",
preview: "Sends as",
previewEmpty: "(incomplete)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Every {n} min",
everyHours: "Every {n} h",
everyDays: "Every {n} d",
dailyAt: "Daily at {time}",
weeklyAt: "Weekly on {days} at {time}",
monthlyAt: "Monthly on the {day} at {time}",
onceAt: "Once at {time}",
},
deliverTo: "Deliver to",
scheduledJobs: "Scheduled Jobs",
noJobs: "No cron jobs configured. Create one above.",
+51
View File
@@ -141,6 +141,22 @@ export const es: Translations = {
"Esto elimina permanentemente la conversación y todos sus mensajes. No se puede deshacer.",
sessionDeleted: "Sesión eliminada",
failedToDelete: "No se pudo eliminar la sesión",
deleteEmpty: "Eliminar vacías",
deleteEmptyConfirmTitle: "¿Eliminar sesiones vacías?",
deleteEmptyConfirmMessage:
"Esto elimina permanentemente {count} sesiones que no tienen mensajes. Se omiten las sesiones activas y archivadas. Esta acción no se puede deshacer.",
emptySessionsDeleted: "{count} sesiones vacías eliminadas",
failedToDeleteEmpty: "No se pudieron eliminar las sesiones vacías",
selectSession: "Seleccionar sesión",
selectAllOnPage: "Seleccionar todas en esta página",
clearSelection: "Limpiar selección",
selectedCount: "{count} seleccionadas",
deleteSelected: "Eliminar {count}",
deleteSelectedConfirmTitle: "¿Eliminar {count} sesiones?",
deleteSelectedConfirmMessage:
"Esto elimina permanentemente {count} sesiones seleccionadas y todos sus mensajes. No se puede deshacer.",
selectedSessionsDeleted: "{count} sesiones eliminadas",
failedToDeleteSelected: "No se pudieron eliminar las sesiones seleccionadas",
resumeInChat: "Reanudar en el chat",
previousPage: "Página anterior",
nextPage: "Página siguiente",
@@ -211,6 +227,41 @@ export const es: Translations = {
promptPlaceholder: "¿Qué debe hacer el agente en cada ejecución?",
schedule: "Programación (expresión cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Programación",
scheduleModes: {
interval: "Cada intervalo",
daily: "Diariamente",
weekly: "Semanalmente",
monthly: "Mensualmente",
once: "Una vez",
custom: "Personalizado (expresión cron)",
intervalEvery: "Cada",
intervalUnit: "Unidad",
unitMinutes: "minutos",
unitHours: "horas",
unitDays: "días",
timeOfDay: "Hora del día",
weekdays: "Días de la semana",
weekdaysShort: ["Dom", "Lun", "Mar", "Mié", "Jue", "Vie", "Sáb"],
dayOfMonth: "Día del mes",
onceAt: "Ejecutar el",
customLabel: "Expresión cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expresión cron de cinco campos (minuto, hora, día, mes, día de la semana).",
preview: "Se envía como",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Cada {n} min",
everyHours: "Cada {n} h",
everyDays: "Cada {n} d",
dailyAt: "Diariamente a las {time}",
weeklyAt: "Semanalmente los {days} a las {time}",
monthlyAt: "Mensualmente el {day} a las {time}",
onceAt: "Una vez el {time}",
},
deliverTo: "Entregar a",
scheduledJobs: "Tareas programadas",
noJobs: "No hay tareas cron configuradas. Crea una arriba.",
+51
View File
@@ -141,6 +141,22 @@ export const fr: Translations = {
"Cela supprime définitivement la conversation et tous ses messages. Cette action est irréversible.",
sessionDeleted: "Session supprimée",
failedToDelete: "Échec de la suppression de la session",
deleteEmpty: "Supprimer les vides",
deleteEmptyConfirmTitle: "Supprimer les sessions vides ?",
deleteEmptyConfirmMessage:
"Cela supprime définitivement {count} sessions sans messages. Les sessions actives et archivées sont ignorées. Cette action est irréversible.",
emptySessionsDeleted: "{count} sessions vides supprimées",
failedToDeleteEmpty: "Échec de la suppression des sessions vides",
selectSession: "Sélectionner la session",
selectAllOnPage: "Tout sélectionner sur cette page",
clearSelection: "Effacer la sélection",
selectedCount: "{count} sélectionnée(s)",
deleteSelected: "Supprimer {count}",
deleteSelectedConfirmTitle: "Supprimer {count} sessions ?",
deleteSelectedConfirmMessage:
"Cela supprime définitivement {count} sessions sélectionnées et tous leurs messages. Cette action est irréversible.",
selectedSessionsDeleted: "{count} sessions supprimées",
failedToDeleteSelected: "Échec de la suppression des sessions sélectionnées",
resumeInChat: "Reprendre dans le chat",
previousPage: "Page précédente",
nextPage: "Page suivante",
@@ -211,6 +227,41 @@ export const fr: Translations = {
promptPlaceholder: "Que doit faire l'agent à chaque exécution ?",
schedule: "Planning (expression cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Planification",
scheduleModes: {
interval: "Intervalle récurrent",
daily: "Quotidien",
weekly: "Hebdomadaire",
monthly: "Mensuel",
once: "Une fois",
custom: "Personnalisé (expression cron)",
intervalEvery: "Toutes les",
intervalUnit: "Unité",
unitMinutes: "minutes",
unitHours: "heures",
unitDays: "jours",
timeOfDay: "Heure de la journée",
weekdays: "Jours de la semaine",
weekdaysShort: ["Dim", "Lun", "Mar", "Mer", "Jeu", "Ven", "Sam"],
dayOfMonth: "Jour du mois",
onceAt: "Exécuter le",
customLabel: "Expression cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expression cron à cinq champs (minute, heure, jour, mois, jour de la semaine).",
preview: "Envoyé sous la forme",
previewEmpty: "(incomplet)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Toutes les {n} min",
everyHours: "Toutes les {n} h",
everyDays: "Tous les {n} j",
dailyAt: "Tous les jours à {time}",
weeklyAt: "Chaque {days} à {time}",
monthlyAt: "Le {day} de chaque mois à {time}",
onceAt: "Une fois le {time}",
},
deliverTo: "Livrer à",
scheduledJobs: "Tâches planifiées",
noJobs: "Aucune tâche cron configurée. Créez-en une ci-dessus.",
+59
View File
@@ -141,6 +141,22 @@ export const ga: Translations = {
"Baineann sé seo an comhrá agus a chuid teachtaireachtaí ar fad go buan. Ní féidir é seo a chealú.",
sessionDeleted: "Seisiún scriosta",
failedToDelete: "Theip ar scriosadh an tseisiúin",
deleteEmpty: "Scrios folamh",
deleteEmptyConfirmTitle: "Scrios seisiúin fholmha?",
deleteEmptyConfirmMessage:
"Baintear {count} seisiúin gan teachtaireachtaí ar bhealach buan. Ní scriostar seisiúin ghníomhacha agus seisiúin chartlainne. Ní féidir é seo a chealú.",
emptySessionsDeleted: "{count} seisiúin fholmha scriosta",
failedToDeleteEmpty: "Theip ar scriosadh na seisiún folmha",
selectSession: "Roghnaigh seisiún",
selectAllOnPage: "Roghnaigh gach ceann ar an leathanach seo",
clearSelection: "Glan an rogha",
selectedCount: "{count} roghnaithe",
deleteSelected: "Scrios {count}",
deleteSelectedConfirmTitle: "Scrios {count} seisiún?",
deleteSelectedConfirmMessage:
"Bainfear {count} seisiún roghnaithe agus a dteachtaireachtaí go léir go buan. Ní féidir é seo a chur ar ais.",
selectedSessionsDeleted: "Scriosadh {count} seisiún",
failedToDeleteSelected: "Theip ar scriosadh na seisiún roghnaithe",
resumeInChat: "Lean ar aghaidh sa chomhrá",
previousPage: "Leathanach roimhe seo",
nextPage: "An chéad leathanach eile",
@@ -211,6 +227,49 @@ export const ga: Translations = {
promptPlaceholder: "Cad ba chóir don agent a dhéanamh ag gach rith?",
schedule: "Sceideal (slonn cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Sceideal",
scheduleModes: {
interval: "Eatramh athfhillteach",
daily: "Go laethúil",
weekly: "Go seachtainiúil",
monthly: "Go míosúil",
once: "Uair amháin",
custom: "Saincheaptha (slonn cron)",
intervalEvery: "Gach",
intervalUnit: "Aonad",
unitMinutes: "nóiméad",
unitHours: "uair",
unitDays: "lá",
timeOfDay: "Am an lae",
weekdays: "Laethanta na seachtaine",
weekdaysShort: [
"Domh",
"Luan",
"Máirt",
"Céad",
"Déar",
"Aoine",
"Sath",
],
dayOfMonth: "Lá den mhí",
onceAt: "Rith ag",
customLabel: "Slonn cron",
customPlaceholder: "0 9 * * *",
customHint:
"Slonn cron cúig réimse (nóiméad, uair, lá, mí, lá den tseachtain).",
preview: "Seoltar mar",
previewEmpty: "(neamhiomlán)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Gach {n} nóim",
everyHours: "Gach {n} u",
everyDays: "Gach {n} lá",
dailyAt: "Go laethúil ag {time}",
weeklyAt: "Gach {days} ag {time}",
monthlyAt: "An {day} de gach mí ag {time}",
onceAt: "Uair amháin ag {time}",
},
deliverTo: "Seachadadh chuig",
scheduledJobs: "Poist sceidealta",
noJobs: "Níl poist cron cumraithe. Cruthaigh ceann thuas.",
+51
View File
@@ -141,6 +141,22 @@ export const hu: Translations = {
"Ez véglegesen eltávolítja a beszélgetést és minden üzenetét. A művelet nem vonható vissza.",
sessionDeleted: "Munkamenet törölve",
failedToDelete: "Nem sikerült törölni a munkamenetet",
deleteEmpty: "Üresek törlése",
deleteEmptyConfirmTitle: "Üres munkamenetek törlése?",
deleteEmptyConfirmMessage:
"Ez véglegesen eltávolít {count} olyan munkamenetet, amely nem tartalmaz üzenetet. Az aktív és archivált munkameneteket kihagyja. Ez nem vonható vissza.",
emptySessionsDeleted: "{count} üres munkamenet törölve",
failedToDeleteEmpty: "Nem sikerült törölni az üres munkameneteket",
selectSession: "Munkamenet kijelölése",
selectAllOnPage: "Az oldalon mindegyik kijelölése",
clearSelection: "Kijelölés törlése",
selectedCount: "{count} kijelölve",
deleteSelected: "{count} törlése",
deleteSelectedConfirmTitle: "{count} munkamenet törlése?",
deleteSelectedConfirmMessage:
"Ez véglegesen eltávolítja a kijelölt {count} munkamenetet és minden üzenetüket. A művelet nem vonható vissza.",
selectedSessionsDeleted: "{count} munkamenet törölve",
failedToDeleteSelected: "Nem sikerült törölni a kijelölt munkameneteket",
resumeInChat: "Folytatás a csevegésben",
previousPage: "Előző oldal",
nextPage: "Következő oldal",
@@ -211,6 +227,41 @@ export const hu: Translations = {
promptPlaceholder: "Mit tegyen az ügynök minden futtatáskor?",
schedule: "Ütemezés (cron-kifejezés)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Ütemezés",
scheduleModes: {
interval: "Ismétlődő intervallum",
daily: "Naponta",
weekly: "Hetente",
monthly: "Havonta",
once: "Egyszer",
custom: "Egyéni (cron kifejezés)",
intervalEvery: "Minden",
intervalUnit: "Egység",
unitMinutes: "perc",
unitHours: "óra",
unitDays: "nap",
timeOfDay: "Napszak",
weekdays: "Hét napjai",
weekdaysShort: ["V", "H", "K", "Sze", "Cs", "P", "Szo"],
dayOfMonth: "Hónap napja",
onceAt: "Futtatás ekkor",
customLabel: "Cron kifejezés",
customPlaceholder: "0 9 * * *",
customHint:
"Öt mezős cron kifejezés (perc, óra, nap, hónap, hét napja).",
preview: "Elküldve mint",
previewEmpty: "(hiányos)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n} percenként",
everyHours: "{n} óránként",
everyDays: "{n} naponta",
dailyAt: "Naponta {time}-kor",
weeklyAt: "Hetente {days} {time}-kor",
monthlyAt: "Havonta {day} {time}-kor",
onceAt: "Egyszer {time}-kor",
},
deliverTo: "Kézbesítés ide",
scheduledJobs: "Ütemezett feladatok",
noJobs: "Nincs beállított cron-feladat. Hozzon létre egyet fent.",
+51
View File
@@ -141,6 +141,22 @@ export const it: Translations = {
"Questa operazione rimuove definitivamente la conversazione e tutti i suoi messaggi. Non può essere annullata.",
sessionDeleted: "Sessione eliminata",
failedToDelete: "Eliminazione della sessione non riuscita",
deleteEmpty: "Elimina vuote",
deleteEmptyConfirmTitle: "Eliminare le sessioni vuote?",
deleteEmptyConfirmMessage:
"Questa azione rimuove in modo permanente {count} sessioni senza messaggi. Le sessioni attive e archiviate vengono ignorate. L'azione non può essere annullata.",
emptySessionsDeleted: "{count} sessioni vuote eliminate",
failedToDeleteEmpty: "Impossibile eliminare le sessioni vuote",
selectSession: "Seleziona sessione",
selectAllOnPage: "Seleziona tutte in questa pagina",
clearSelection: "Annulla selezione",
selectedCount: "{count} selezionate",
deleteSelected: "Elimina {count}",
deleteSelectedConfirmTitle: "Eliminare {count} sessioni?",
deleteSelectedConfirmMessage:
"Verranno eliminate definitivamente {count} sessioni selezionate e tutti i loro messaggi. L'operazione non può essere annullata.",
selectedSessionsDeleted: "{count} sessioni eliminate",
failedToDeleteSelected: "Impossibile eliminare le sessioni selezionate",
resumeInChat: "Riprendi nella chat",
previousPage: "Pagina precedente",
nextPage: "Pagina successiva",
@@ -211,6 +227,41 @@ export const it: Translations = {
promptPlaceholder: "Cosa deve fare l'agente a ogni esecuzione?",
schedule: "Pianificazione (espressione cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Pianificazione",
scheduleModes: {
interval: "Intervallo ricorrente",
daily: "Giornaliero",
weekly: "Settimanale",
monthly: "Mensile",
once: "Una volta",
custom: "Personalizzato (espressione cron)",
intervalEvery: "Ogni",
intervalUnit: "Unità",
unitMinutes: "minuti",
unitHours: "ore",
unitDays: "giorni",
timeOfDay: "Ora del giorno",
weekdays: "Giorni della settimana",
weekdaysShort: ["Dom", "Lun", "Mar", "Mer", "Gio", "Ven", "Sab"],
dayOfMonth: "Giorno del mese",
onceAt: "Esegui il",
customLabel: "Espressione cron",
customPlaceholder: "0 9 * * *",
customHint:
"Espressione cron a cinque campi (minuto, ora, giorno, mese, giorno della settimana).",
preview: "Inviato come",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Ogni {n} min",
everyHours: "Ogni {n} h",
everyDays: "Ogni {n} g",
dailyAt: "Tutti i giorni alle {time}",
weeklyAt: "Ogni {days} alle {time}",
monthlyAt: "Il {day} di ogni mese alle {time}",
onceAt: "Una volta il {time}",
},
deliverTo: "Consegna a",
scheduledJobs: "Attività pianificate",
noJobs: "Nessuna attività cron configurata. Creane una sopra.",
+50
View File
@@ -141,6 +141,22 @@ export const ja: Translations = {
"会話とそのすべてのメッセージが完全に削除されます。この操作は取り消せません。",
sessionDeleted: "セッションを削除しました",
failedToDelete: "セッションの削除に失敗しました",
deleteEmpty: "空を削除",
deleteEmptyConfirmTitle: "空のセッションを削除しますか?",
deleteEmptyConfirmMessage:
"メッセージのない {count} 件のセッションを完全に削除します。アクティブおよびアーカイブされたセッションはスキップされます。この操作は元に戻せません。",
emptySessionsDeleted: "{count} 件の空のセッションを削除しました",
failedToDeleteEmpty: "空のセッションの削除に失敗しました",
selectSession: "セッションを選択",
selectAllOnPage: "このページの全てを選択",
clearSelection: "選択を解除",
selectedCount: "{count}件選択中",
deleteSelected: "{count}件削除",
deleteSelectedConfirmTitle: "{count}件のセッションを削除しますか?",
deleteSelectedConfirmMessage:
"選択した{count}件のセッションとそのすべてのメッセージが完全に削除されます。この操作は取り消せません。",
selectedSessionsDeleted: "{count}件のセッションを削除しました",
failedToDeleteSelected: "選択したセッションの削除に失敗しました",
resumeInChat: "チャットで再開",
previousPage: "前のページ",
nextPage: "次のページ",
@@ -211,6 +227,40 @@ export const ja: Translations = {
promptPlaceholder: "実行ごとにエージェントが行う内容は?",
schedule: "スケジュール (cron 式)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "スケジュール",
scheduleModes: {
interval: "繰り返し間隔",
daily: "毎日",
weekly: "毎週",
monthly: "毎月",
once: "1回のみ",
custom: "カスタム(cron式)",
intervalEvery: "実行間隔",
intervalUnit: "単位",
unitMinutes: "分",
unitHours: "時間",
unitDays: "日",
timeOfDay: "時刻",
weekdays: "曜日",
weekdaysShort: ["日", "月", "火", "水", "木", "金", "土"],
dayOfMonth: "日付",
onceAt: "実行日時",
customLabel: "cron式",
customPlaceholder: "0 9 * * *",
customHint: "5フィールドのcron式(分、時、日、月、曜日)。",
preview: "送信形式",
previewEmpty: "(未入力)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n}分ごと",
everyHours: "{n}時間ごと",
everyDays: "{n}日ごと",
dailyAt: "毎日 {time}",
weeklyAt: "毎週 {days} {time}",
monthlyAt: "毎月{day} {time}",
onceAt: "{time} に1回",
},
deliverTo: "配信先",
scheduledJobs: "スケジュール済みジョブ",
noJobs: "Cron ジョブが設定されていません。上で作成してください。",
+50
View File
@@ -141,6 +141,22 @@ export const ko: Translations = {
"이 작업은 대화와 모든 메시지를 영구적으로 제거합니다. 되돌릴 수 없습니다.",
sessionDeleted: "세션이 삭제되었습니다",
failedToDelete: "세션 삭제에 실패했습니다",
deleteEmpty: "빈 세션 삭제",
deleteEmptyConfirmTitle: "빈 세션을 삭제하시겠습니까?",
deleteEmptyConfirmMessage:
"메시지가 없는 {count}개의 세션을 영구적으로 삭제합니다. 활성 및 보관된 세션은 건너뜁니다. 이 작업은 되돌릴 수 없습니다.",
emptySessionsDeleted: "빈 세션 {count}개 삭제됨",
failedToDeleteEmpty: "빈 세션 삭제에 실패했습니다",
selectSession: "세션 선택",
selectAllOnPage: "이 페이지 전체 선택",
clearSelection: "선택 해제",
selectedCount: "{count}개 선택됨",
deleteSelected: "{count}개 삭제",
deleteSelectedConfirmTitle: "{count}개 세션을 삭제하시겠습니까?",
deleteSelectedConfirmMessage:
"선택한 {count}개 세션과 모든 메시지가 영구적으로 제거됩니다. 이 작업은 취소할 수 없습니다.",
selectedSessionsDeleted: "{count}개 세션이 삭제되었습니다",
failedToDeleteSelected: "선택한 세션 삭제에 실패했습니다",
resumeInChat: "채팅에서 다시 시작",
previousPage: "이전 페이지",
nextPage: "다음 페이지",
@@ -211,6 +227,40 @@ export const ko: Translations = {
promptPlaceholder: "에이전트가 매 실행 시 무엇을 해야 합니까?",
schedule: "스케줄 (cron 표현식)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "일정",
scheduleModes: {
interval: "반복 간격",
daily: "매일",
weekly: "매주",
monthly: "매월",
once: "한 번",
custom: "사용자 지정 (cron 표현식)",
intervalEvery: "실행 간격",
intervalUnit: "단위",
unitMinutes: "분",
unitHours: "시간",
unitDays: "일",
timeOfDay: "시각",
weekdays: "요일",
weekdaysShort: ["일", "월", "화", "수", "목", "금", "토"],
dayOfMonth: "날짜",
onceAt: "실행 시각",
customLabel: "cron 표현식",
customPlaceholder: "0 9 * * *",
customHint: "5개 필드의 cron 표현식 (분, 시, 일, 월, 요일).",
preview: "전송 형식",
previewEmpty: "(미완성)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "{n}분마다",
everyHours: "{n}시간마다",
everyDays: "{n}일마다",
dailyAt: "매일 {time}",
weeklyAt: "매주 {days} {time}",
monthlyAt: "매월 {day} {time}",
onceAt: "{time}에 한 번",
},
deliverTo: "전달 대상",
scheduledJobs: "예약된 작업",
noJobs: "구성된 cron 작업이 없습니다. 위에서 하나 만드세요.",
+51
View File
@@ -141,6 +141,22 @@ export const pt: Translations = {
"Esta ação remove permanentemente a conversa e todas as suas mensagens. Não é possível anular.",
sessionDeleted: "Sessão eliminada",
failedToDelete: "Falha ao eliminar a sessão",
deleteEmpty: "Eliminar vazias",
deleteEmptyConfirmTitle: "Eliminar sessões vazias?",
deleteEmptyConfirmMessage:
"Isto remove permanentemente {count} sessões sem mensagens. As sessões ativas e arquivadas são ignoradas. Esta ação não pode ser desfeita.",
emptySessionsDeleted: "{count} sessões vazias eliminadas",
failedToDeleteEmpty: "Falha ao eliminar sessões vazias",
selectSession: "Selecionar sessão",
selectAllOnPage: "Selecionar todas nesta página",
clearSelection: "Limpar seleção",
selectedCount: "{count} selecionadas",
deleteSelected: "Eliminar {count}",
deleteSelectedConfirmTitle: "Eliminar {count} sessões?",
deleteSelectedConfirmMessage:
"Isto remove permanentemente {count} sessões selecionadas e todas as suas mensagens. Não pode ser desfeito.",
selectedSessionsDeleted: "{count} sessões eliminadas",
failedToDeleteSelected: "Falha ao eliminar as sessões selecionadas",
resumeInChat: "Retomar no Chat",
previousPage: "Página anterior",
nextPage: "Página seguinte",
@@ -211,6 +227,41 @@ export const pt: Translations = {
promptPlaceholder: "O que deve o agente fazer em cada execução?",
schedule: "Agendamento (expressão cron)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Agendamento",
scheduleModes: {
interval: "Intervalo recorrente",
daily: "Diariamente",
weekly: "Semanalmente",
monthly: "Mensalmente",
once: "Uma vez",
custom: "Personalizado (expressão cron)",
intervalEvery: "A cada",
intervalUnit: "Unidade",
unitMinutes: "minutos",
unitHours: "horas",
unitDays: "dias",
timeOfDay: "Hora do dia",
weekdays: "Dias da semana",
weekdaysShort: ["Dom", "Seg", "Ter", "Qua", "Qui", "Sex", "Sáb"],
dayOfMonth: "Dia do mês",
onceAt: "Executar em",
customLabel: "Expressão cron",
customPlaceholder: "0 9 * * *",
customHint:
"Expressão cron de cinco campos (minuto, hora, dia, mês, dia da semana).",
preview: "Enviado como",
previewEmpty: "(incompleta)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "A cada {n} min",
everyHours: "A cada {n} h",
everyDays: "A cada {n} d",
dailyAt: "Diariamente às {time}",
weeklyAt: "Semanalmente {days} às {time}",
monthlyAt: "Mensalmente no dia {day} às {time}",
onceAt: "Uma vez em {time}",
},
deliverTo: "Entregar a",
scheduledJobs: "Tarefas agendadas",
noJobs: "Sem tarefas cron configuradas. Crie uma acima.",
+51
View File
@@ -141,6 +141,22 @@ export const ru: Translations = {
"Это безвозвратно удалит разговор и все его сообщения. Действие нельзя отменить.",
sessionDeleted: "Сессия удалена",
failedToDelete: "Не удалось удалить сессию",
deleteEmpty: "Удалить пустые",
deleteEmptyConfirmTitle: "Удалить пустые сессии?",
deleteEmptyConfirmMessage:
"Это безвозвратно удалит {count} сессий без сообщений. Активные и архивные сессии будут пропущены. Это действие нельзя отменить.",
emptySessionsDeleted: "Удалено пустых сессий: {count}",
failedToDeleteEmpty: "Не удалось удалить пустые сессии",
selectSession: "Выбрать сессию",
selectAllOnPage: "Выбрать все на этой странице",
clearSelection: "Снять выделение",
selectedCount: "Выбрано: {count}",
deleteSelected: "Удалить {count}",
deleteSelectedConfirmTitle: "Удалить {count} сессий?",
deleteSelectedConfirmMessage:
"Это безвозвратно удалит {count} выбранных сессий и все их сообщения. Это действие нельзя отменить.",
selectedSessionsDeleted: "Удалено сессий: {count}",
failedToDeleteSelected: "Не удалось удалить выбранные сессии",
resumeInChat: "Продолжить в чате",
previousPage: "Предыдущая страница",
nextPage: "Следующая страница",
@@ -211,6 +227,41 @@ export const ru: Translations = {
promptPlaceholder: "Что должен делать агент при каждом запуске?",
schedule: "Расписание (cron-выражение)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Расписание",
scheduleModes: {
interval: "Повторяющийся интервал",
daily: "Ежедневно",
weekly: "Еженедельно",
monthly: "Ежемесячно",
once: "Один раз",
custom: "Произвольное (cron-выражение)",
intervalEvery: "Каждые",
intervalUnit: "Единицы",
unitMinutes: "минут",
unitHours: "часов",
unitDays: "дней",
timeOfDay: "Время суток",
weekdays: "Дни недели",
weekdaysShort: ["Вс", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"],
dayOfMonth: "День месяца",
onceAt: "Выполнить в",
customLabel: "Cron-выражение",
customPlaceholder: "0 9 * * *",
customHint:
"Cron-выражение из пяти полей (минута, час, день, месяц, день недели).",
preview: "Отправляется как",
previewEmpty: "(не заполнено)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Каждые {n} мин",
everyHours: "Каждые {n} ч",
everyDays: "Каждые {n} дн",
dailyAt: "Ежедневно в {time}",
weeklyAt: "Еженедельно в {days} в {time}",
monthlyAt: "Ежемесячно {day} числа в {time}",
onceAt: "Один раз {time}",
},
deliverTo: "Доставить в",
scheduledJobs: "Запланированные задачи",
noJobs: "Cron-задачи не настроены. Создайте задачу выше.",
+51
View File
@@ -141,6 +141,22 @@ export const tr: Translations = {
"Bu, konuşmayı ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.",
sessionDeleted: "Oturum silindi",
failedToDelete: "Oturum silinemedi",
deleteEmpty: "Boşları sil",
deleteEmptyConfirmTitle: "Boş oturumlar silinsin mi?",
deleteEmptyConfirmMessage:
"Bu işlem, mesaj içermeyen {count} oturumu kalıcı olarak siler. Aktif ve arşivlenmiş oturumlar atlanır. Bu işlem geri alınamaz.",
emptySessionsDeleted: "{count} boş oturum silindi",
failedToDeleteEmpty: "Boş oturumlar silinemedi",
selectSession: "Oturumu seç",
selectAllOnPage: "Bu sayfadakilerin tümünü seç",
clearSelection: "Seçimi temizle",
selectedCount: "{count} seçildi",
deleteSelected: "{count} sil",
deleteSelectedConfirmTitle: "{count} oturum silinsin mi?",
deleteSelectedConfirmMessage:
"Bu, seçilen {count} oturumu ve tüm mesajlarını kalıcı olarak siler. Bu işlem geri alınamaz.",
selectedSessionsDeleted: "{count} oturum silindi",
failedToDeleteSelected: "Seçilen oturumlar silinemedi",
resumeInChat: "Sohbette Devam Et",
previousPage: "Önceki sayfa",
nextPage: "Sonraki sayfa",
@@ -211,6 +227,41 @@ export const tr: Translations = {
promptPlaceholder: "Agent her çalıştırmada ne yapmalı?",
schedule: "Zamanlama (cron ifadesi)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Zamanlama",
scheduleModes: {
interval: "Tekrarlanan aralık",
daily: "Günlük",
weekly: "Haftalık",
monthly: "Aylık",
once: "Bir kez",
custom: "Özel (cron ifadesi)",
intervalEvery: "Her",
intervalUnit: "Birim",
unitMinutes: "dakika",
unitHours: "saat",
unitDays: "gün",
timeOfDay: "Günün saati",
weekdays: "Haftanın günleri",
weekdaysShort: ["Paz", "Pzt", "Sal", "Çar", "Per", "Cum", "Cmt"],
dayOfMonth: "Ayın günü",
onceAt: "Çalıştırma zamanı",
customLabel: "Cron ifadesi",
customPlaceholder: "0 9 * * *",
customHint:
"Beş alanlı cron ifadesi (dakika, saat, gün, ay, haftanın günü).",
preview: "Gönderilecek olan",
previewEmpty: "(eksik)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Her {n} dk",
everyHours: "Her {n} sa",
everyDays: "Her {n} gün",
dailyAt: "Her gün {time}",
weeklyAt: "Her hafta {days} {time}",
monthlyAt: "Her ayın {day} günü {time}",
onceAt: "{time} bir kez",
},
deliverTo: "Şuraya teslim et",
scheduledJobs: "Zamanlanmış Görevler",
noJobs: "Yapılandırılmış cron görevi yok. Yukarıdan bir tane oluşturun.",
+48
View File
@@ -158,6 +158,20 @@ export interface Translations {
confirmDeleteMessage: string;
sessionDeleted: string;
failedToDelete: string;
deleteEmpty: string;
deleteEmptyConfirmTitle: string;
deleteEmptyConfirmMessage: string;
emptySessionsDeleted: string;
failedToDeleteEmpty: string;
selectSession: string;
selectAllOnPage: string;
clearSelection: string;
selectedCount: string;
deleteSelected: string;
deleteSelectedConfirmTitle: string;
deleteSelectedConfirmMessage: string;
selectedSessionsDeleted: string;
failedToDeleteSelected: string;
resumeInChat: string;
previousPage: string;
nextPage: string;
@@ -231,6 +245,40 @@ export interface Translations {
promptPlaceholder: string;
schedule: string;
schedulePlaceholder: string;
scheduleMode: string;
scheduleModes: {
interval: string;
daily: string;
weekly: string;
monthly: string;
once: string;
custom: string;
intervalEvery: string;
intervalUnit: string;
unitMinutes: string;
unitHours: string;
unitDays: string;
timeOfDay: string;
weekdays: string;
weekdaysShort: [string, string, string, string, string, string, string];
dayOfMonth: string;
onceAt: string;
customLabel: string;
customPlaceholder: string;
customHint: string;
preview: string;
previewEmpty: string;
};
scheduleDescribe: {
none: string;
everyMinutes: string;
everyHours: string;
everyDays: string;
dailyAt: string;
weeklyAt: string;
monthlyAt: string;
onceAt: string;
};
deliverTo: string;
scheduledJobs: string;
noJobs: string;
+51
View File
@@ -141,6 +141,22 @@ export const uk: Translations = {
"Це назавжди видалить розмову та всі її повідомлення. Цю дію не можна скасувати.",
sessionDeleted: "Сесію видалено",
failedToDelete: "Не вдалося видалити сесію",
deleteEmpty: "Видалити порожні",
deleteEmptyConfirmTitle: "Видалити порожні сесії?",
deleteEmptyConfirmMessage:
"Це остаточно видалить {count} сесій без повідомлень. Активні та архівні сесії пропускаються. Цю дію неможливо скасувати.",
emptySessionsDeleted: "Видалено порожніх сесій: {count}",
failedToDeleteEmpty: "Не вдалося видалити порожні сесії",
selectSession: "Вибрати сесію",
selectAllOnPage: "Вибрати всі на цій сторінці",
clearSelection: "Скинути вибір",
selectedCount: "Вибрано: {count}",
deleteSelected: "Видалити {count}",
deleteSelectedConfirmTitle: "Видалити {count} сесій?",
deleteSelectedConfirmMessage:
"Це назавжди видалить {count} вибраних сесій і всі їхні повідомлення. Цю дію неможливо скасувати.",
selectedSessionsDeleted: "Видалено сесій: {count}",
failedToDeleteSelected: "Не вдалося видалити вибрані сесії",
resumeInChat: "Продовжити в чаті",
previousPage: "Попередня сторінка",
nextPage: "Наступна сторінка",
@@ -211,6 +227,41 @@ export const uk: Translations = {
promptPlaceholder: "Що агент має робити при кожному запуску?",
schedule: "Розклад (cron-вираз)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "Розклад",
scheduleModes: {
interval: "Повторюваний інтервал",
daily: "Щодня",
weekly: "Щотижня",
monthly: "Щомісяця",
once: "Один раз",
custom: "Користувацьке (cron-вираз)",
intervalEvery: "Кожні",
intervalUnit: "Одиниці",
unitMinutes: "хвилин",
unitHours: "годин",
unitDays: "днів",
timeOfDay: "Час доби",
weekdays: "Дні тижня",
weekdaysShort: ["Нд", "Пн", "Вт", "Ср", "Чт", "Пт", "Сб"],
dayOfMonth: "День місяця",
onceAt: "Виконати о",
customLabel: "Cron-вираз",
customPlaceholder: "0 9 * * *",
customHint:
"П'ятиполевий cron-вираз (хвилина, година, день, місяць, день тижня).",
preview: "Надсилається як",
previewEmpty: "(не заповнено)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "Кожні {n} хв",
everyHours: "Кожні {n} год",
everyDays: "Кожні {n} дн",
dailyAt: "Щодня о {time}",
weeklyAt: "Щотижня у {days} о {time}",
monthlyAt: "Щомісяця {day} числа о {time}",
onceAt: "Один раз {time}",
},
deliverTo: "Надіслати на",
scheduledJobs: "Заплановані завдання",
noJobs: "Cron-завдань не налаштовано. Створіть одне вище.",
+50
View File
@@ -141,6 +141,22 @@ export const zhHant: Translations = {
"此操作將永久移除對話及其所有訊息,無法復原。",
sessionDeleted: "工作階段已刪除",
failedToDelete: "刪除工作階段失敗",
deleteEmpty: "刪除空工作階段",
deleteEmptyConfirmTitle: "刪除空工作階段?",
deleteEmptyConfirmMessage:
"這將永久刪除 {count} 個沒有訊息的工作階段。活動中與已封存的工作階段將被略過。此動作無法復原。",
emptySessionsDeleted: "已刪除 {count} 個空工作階段",
failedToDeleteEmpty: "刪除空工作階段失敗",
selectSession: "選擇工作階段",
selectAllOnPage: "全選本頁",
clearSelection: "清除選擇",
selectedCount: "已選擇 {count} 個",
deleteSelected: "刪除 {count} 個",
deleteSelectedConfirmTitle: "刪除 {count} 個工作階段?",
deleteSelectedConfirmMessage:
"此操作將永久刪除所選的 {count} 個工作階段及其所有訊息。無法復原。",
selectedSessionsDeleted: "已刪除 {count} 個工作階段",
failedToDeleteSelected: "刪除所選工作階段失敗",
resumeInChat: "在對話中繼續",
previousPage: "上一頁",
nextPage: "下一頁",
@@ -211,6 +227,40 @@ export const zhHant: Translations = {
promptPlaceholder: "代理每次執行時應做什麼?",
schedule: "排程(cron 運算式)",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "排程",
scheduleModes: {
interval: "重複間隔",
daily: "每日",
weekly: "每週",
monthly: "每月",
once: "僅一次",
custom: "自訂(cron 運算式)",
intervalEvery: "每",
intervalUnit: "單位",
unitMinutes: "分鐘",
unitHours: "小時",
unitDays: "天",
timeOfDay: "時間",
weekdays: "星期",
weekdaysShort: ["日", "一", "二", "三", "四", "五", "六"],
dayOfMonth: "日期",
onceAt: "執行時間",
customLabel: "cron 運算式",
customPlaceholder: "0 9 * * *",
customHint: "五欄位 cron 運算式(分、時、日、月、星期)。",
preview: "傳送為",
previewEmpty: "(未完成)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "每 {n} 分鐘",
everyHours: "每 {n} 小時",
everyDays: "每 {n} 天",
dailyAt: "每天 {time}",
weeklyAt: "每週 {days} {time}",
monthlyAt: "每月{day} {time}",
onceAt: "{time} 執行一次",
},
deliverTo: "傳送至",
scheduledJobs: "已排程任務",
noJobs: "尚未設定排程任務。請於上方建立。",
+50
View File
@@ -139,6 +139,22 @@ export const zh: Translations = {
confirmDeleteMessage: "此操作将永久删除对话及其所有消息,无法恢复。",
sessionDeleted: "会话已删除",
failedToDelete: "删除会话失败",
deleteEmpty: "删除空会话",
deleteEmptyConfirmTitle: "删除空会话?",
deleteEmptyConfirmMessage:
"这将永久删除 {count} 个没有消息的会话。活动和已归档的会话将被跳过。此操作无法撤销。",
emptySessionsDeleted: "已删除 {count} 个空会话",
failedToDeleteEmpty: "删除空会话失败",
selectSession: "选择会话",
selectAllOnPage: "全选本页",
clearSelection: "清除选择",
selectedCount: "已选择 {count} 个",
deleteSelected: "删除 {count} 个",
deleteSelectedConfirmTitle: "删除 {count} 个会话?",
deleteSelectedConfirmMessage:
"此操作将永久删除所选的 {count} 个会话及其所有消息。无法撤销。",
selectedSessionsDeleted: "已删除 {count} 个会话",
failedToDeleteSelected: "删除所选会话失败",
resumeInChat: "在对话中继续",
previousPage: "上一页",
nextPage: "下一页",
@@ -208,6 +224,40 @@ export const zh: Translations = {
promptPlaceholder: "代理每次运行时应执行什么操作?",
schedule: "调度表达式(cron",
schedulePlaceholder: "0 9 * * *",
scheduleMode: "调度",
scheduleModes: {
interval: "重复间隔",
daily: "每天",
weekly: "每周",
monthly: "每月",
once: "仅一次",
custom: "自定义(cron 表达式)",
intervalEvery: "每",
intervalUnit: "单位",
unitMinutes: "分钟",
unitHours: "小时",
unitDays: "天",
timeOfDay: "时间",
weekdays: "星期",
weekdaysShort: ["日", "一", "二", "三", "四", "五", "六"],
dayOfMonth: "日期",
onceAt: "执行时间",
customLabel: "cron 表达式",
customPlaceholder: "0 9 * * *",
customHint: "五字段 cron 表达式(分、时、日、月、星期)。",
preview: "发送为",
previewEmpty: "(未完成)",
},
scheduleDescribe: {
none: "—",
everyMinutes: "每 {n} 分钟",
everyHours: "每 {n} 小时",
everyDays: "每 {n} 天",
dailyAt: "每天 {time}",
weeklyAt: "每周 {days} {time}",
monthlyAt: "每月{day} {time}",
onceAt: "{time} 执行一次",
},
deliverTo: "投递至",
scheduledJobs: "已调度任务",
noJobs: "暂无定时任务。在上方创建一个。",
+9
View File
@@ -83,6 +83,15 @@
--theme-radius: 0.5rem;
--theme-spacing-mul: 1;
--theme-density: comfortable;
/* Data-series accents — consumed by Analytics + Models pages for the
input-vs-output token visualisations (chart bars, table values,
legend swatches). Defaults are tuned for the Hermes-teal LENS_0
look: cream input + emerald-400 output read as warm/cool against
the dark canvas. Themes override via ThemeProvider, which emits
these as `--series-input-token` / `--series-output-token`. */
--series-input-token: #ffe6cb;
--series-output-token: #34d399;
}
/* Theme tokens cascade into the document root so every descendant inherits
+12
View File
@@ -238,6 +238,18 @@ export const api = {
fetchJSON<{ ok: boolean }>(`/api/sessions/${encodeURIComponent(id)}`, {
method: "DELETE",
}),
getEmptySessionsCount: () =>
fetchJSON<{ count: number }>("/api/sessions/empty/count"),
deleteEmptySessions: () =>
fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/empty", {
method: "DELETE",
}),
bulkDeleteSessions: (ids: string[]) =>
fetchJSON<{ ok: boolean; deleted: number }>("/api/sessions/bulk-delete", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids }),
}),
renameSession: (id: string, title: string) =>
fetchJSON<{ ok: boolean; title: string }>(
`/api/sessions/${encodeURIComponent(id)}`,
+382
View File
@@ -0,0 +1,382 @@
/**
* Schedule builder helpers for the cron page.
*
* The hermes-agent backend (cron/jobs.py::parse_schedule) accepts a
* surprisingly broad set of string formats:
*
* - Duration (one-shot): "30m", "2h", "1d"
* - Interval (recurring): "every 30m", "every 2h", "every 1d"
* - Cron expression (5-field): "0 9 * * *", "30 14 * * 1,3,5"
* - ISO timestamp (one-shot): "2026-02-03T14:00:00"
*
* Power users can hand-type any of those, but for everyone else the
* dashboard now offers a human-readable picker. This module is the
* pure logic layer behind that picker:
*
* - {@link buildScheduleString} turns the picker's structured state
* into one of the strings above.
* - {@link describeSchedule} goes the other way: takes the structured
* schedule shape the API returns (``CronJob.schedule``) and produces
* a human-readable sentence for the job list. It recognises common
* cron-expression shapes (daily/weekly/monthly) so users don't have
* to parse "30 14 * * 1,3,5" by eye.
*
* Kept dependency-free and locale-string-driven so it tree-shakes
* cleanly and is testable in isolation if we ever wire up vitest here.
*/
/** Picker modes — each renders a different set of inputs in the UI but
* all funnel through {@link buildScheduleString} to a backend-compatible
* string. ``custom`` is the escape hatch for power users who still want
* to type a raw cron expression. */
export type ScheduleMode =
| "interval"
| "daily"
| "weekly"
| "monthly"
| "once"
| "custom";
/** Unit used by interval mode. Backend parses ``m``/``h``/``d`` suffixes. */
export type IntervalUnit = "minutes" | "hours" | "days";
/** Cron weekday convention: Sunday = 0 .. Saturday = 6. Matches what
* croniter expects on the backend (no need to remap on submit). */
export const WEEKDAY_INDEXES = [0, 1, 2, 3, 4, 5, 6] as const;
export type Weekday = (typeof WEEKDAY_INDEXES)[number];
export interface ScheduleBuilderState {
/** Index of which "custom" radio is selected. */
mode: ScheduleMode;
/** Interval mode: positive integer, paired with ``intervalUnit``. */
intervalValue: number;
intervalUnit: IntervalUnit;
/** Daily/weekly/monthly mode: "HH:MM" 24h format from <input type=time>. */
timeOfDay: string;
/** Weekly mode: 0..6, Sunday-first. Empty means "every day", which is
* still valid — we send "*" for the day-of-week cron field. */
weekdays: Weekday[];
/** Monthly mode: 1..31 (no support for "last day of month" sugar — the
* croniter ``L`` extension isn't enabled in the parse_schedule regex). */
dayOfMonth: number;
/** Once mode: ``YYYY-MM-DDTHH:MM`` from <input type=datetime-local>. */
onceAt: string;
/** Custom mode: raw user-typed cron expression. Stored separately so
* flipping between modes doesn't erase the user's work. */
custom: string;
}
/** Default state — "every 30 minutes" is the most-common-cron-pattern
* starting point and avoids forcing the user to pick everything from
* scratch. */
export const DEFAULT_SCHEDULE_STATE: ScheduleBuilderState = {
mode: "interval",
intervalValue: 30,
intervalUnit: "minutes",
timeOfDay: "09:00",
weekdays: [1, 2, 3, 4, 5],
dayOfMonth: 1,
onceAt: "",
custom: "",
};
const UNIT_SUFFIX: Record<IntervalUnit, string> = {
minutes: "m",
hours: "h",
days: "d",
};
/** Build the schedule string from picker state. Returns ``""`` when the
* state is incomplete enough that the backend would 400 — the caller
* uses that to disable the Submit button.
*
* Why we lean on the broad parse_schedule grammar instead of always
* emitting cron expressions: interval syntax ("every 30m") survives a
* backend without ``croniter`` installed and renders more readably in
* the job list. We only emit raw cron when the picker truly needs the
* cron field expressiveness (specific weekdays, specific day-of-month). */
export function buildScheduleString(state: ScheduleBuilderState): string {
switch (state.mode) {
case "interval": {
const n = Math.floor(state.intervalValue);
if (!Number.isFinite(n) || n < 1) return "";
return `every ${n}${UNIT_SUFFIX[state.intervalUnit]}`;
}
case "daily": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
return `${parsed.minute} ${parsed.hour} * * *`;
}
case "weekly": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
// Empty weekday selection → "*" (every day) rather than a backend
// 400. The Daily mode is the cleaner choice for that, but if the
// user toggles all days off in Weekly mode we still emit a valid
// expression instead of breaking the submit.
const days =
state.weekdays.length === 0
? "*"
: [...state.weekdays].sort((a, b) => a - b).join(",");
return `${parsed.minute} ${parsed.hour} * * ${days}`;
}
case "monthly": {
const parsed = parseTimeOfDay(state.timeOfDay);
if (!parsed) return "";
const dom = Math.floor(state.dayOfMonth);
if (!Number.isFinite(dom) || dom < 1 || dom > 31) return "";
return `${parsed.minute} ${parsed.hour} ${dom} * *`;
}
case "once": {
const v = state.onceAt.trim();
if (!v) return "";
// <input type=datetime-local> already emits the
// "YYYY-MM-DDTHH:MM" shape that fromisoformat() accepts directly.
// Append ":00" so the backend's regex hits the "T" branch and
// the seconds component lines up with isoformat() output.
return v.length === 16 ? `${v}:00` : v;
}
case "custom":
return state.custom.trim();
}
}
function parseTimeOfDay(value: string): { hour: number; minute: number } | null {
if (!value || !/^\d{1,2}:\d{2}$/.test(value)) return null;
const [hh, mm] = value.split(":");
const hour = parseInt(hh, 10);
const minute = parseInt(mm, 10);
if (
!Number.isFinite(hour) ||
!Number.isFinite(minute) ||
hour < 0 ||
hour > 23 ||
minute < 0 ||
minute > 59
) {
return null;
}
return { hour, minute };
}
/** Translation surface the human-readable describer needs. Passing it
* in (instead of importing ``useI18n``) keeps the helper pure and
* testable; the CronPage threads ``t.cron.scheduleDescribe`` through. */
export interface ScheduleDescribeStrings {
/** Display when no schedule can be resolved (e.g. legacy/blank job). */
none: string;
/** "Every {n} minute(s)" — caller pluralises via {n}. */
everyMinutes: string;
everyHours: string;
everyDays: string;
/** "Daily at {time}" */
dailyAt: string;
/** "Weekly on {days} at {time}" */
weeklyAt: string;
/** "Monthly on the {day} at {time}" */
monthlyAt: string;
/** "Once at {time}" */
onceAt: string;
/** Weekday short names indexed 0..6 (Sunday-first). */
weekdaysShort: [string, string, string, string, string, string, string];
/** Ordinal suffix builder, e.g. "1st", "22nd". For locales that
* don't use English ordinals, just return ``String(day)``. */
ordinal: (day: number) => string;
}
/** Schedule shape stored on a ``CronJob`` row (see api.ts). */
export interface ScheduleLike {
kind?: string;
expr?: string;
minutes?: number;
run_at?: string;
display?: string;
}
/** Human-readable description of a stored schedule.
*
* Prefers a structured render over the raw ``display`` string so cron
* expressions like ``30 14 * * 1,3,5`` show up as "Weekly on Mon, Wed,
* Fri at 14:30" instead of the raw five-field gibberish. Falls back to
* ``display`` / ``expr`` / ``none`` in that order if we can't make sense
* of the schedule (e.g. exotic cron with ranges, step values, or @reboot
* macros that we'd misrepresent if we tried to "humanize"). */
export function describeSchedule(
schedule: ScheduleLike | undefined,
fallbackDisplay: string | undefined,
strings: ScheduleDescribeStrings,
): string {
if (!schedule) return fallbackDisplay || strings.none;
if (schedule.kind === "interval" && typeof schedule.minutes === "number") {
return describeInterval(schedule.minutes, strings);
}
if (schedule.kind === "once" && schedule.run_at) {
return strings.onceAt.replace(
"{time}",
formatIsoLocal(schedule.run_at, false),
);
}
if (schedule.kind === "cron" && schedule.expr) {
const cronDesc = describeCronExpression(schedule.expr, strings);
if (cronDesc) return cronDesc;
}
// Try the raw expression as a last attempt — for legacy jobs stored
// without ``kind``, the ``schedule_display`` field often *is* the cron
// expression.
if (fallbackDisplay) {
const cronDesc = describeCronExpression(fallbackDisplay, strings);
if (cronDesc) return cronDesc;
return fallbackDisplay;
}
if (schedule.display) return schedule.display;
if (schedule.expr) return schedule.expr;
return strings.none;
}
function describeInterval(
minutes: number,
strings: ScheduleDescribeStrings,
): string {
if (minutes <= 0) return strings.none;
if (minutes % 1440 === 0) {
return strings.everyDays.replace("{n}", String(minutes / 1440));
}
if (minutes % 60 === 0) {
return strings.everyHours.replace("{n}", String(minutes / 60));
}
return strings.everyMinutes.replace("{n}", String(minutes));
}
/** Recognise the common, well-shaped cron patterns and return a
* human sentence for them. Returns ``null`` when the expression has any
* ranges, steps, or other complexity that would be misleading to
* "humanize" — caller falls back to displaying the raw expression so
* the user sees what's actually scheduled.
*
* Strictly 5-field only: the backend ``parse_schedule`` also accepts the
* 6-field ``minute hour dom month dow year`` form, but humanising those
* by destructuring only the first five fields would silently drop the
* year and mislead the user (e.g. ``0 9 * * * 2099`` would read as
* "Daily at 09:00"). 6+ field expressions intentionally fall through to
* the raw-string fallback in {@link describeSchedule}. */
function describeCronExpression(
expr: string,
strings: ScheduleDescribeStrings,
): string | null {
const parts = expr.trim().split(/\s+/);
if (parts.length !== 5) return null;
const [minField, hourField, domField, monField, dowField] = parts;
const month = monField === "*";
if (!month) return null; // we don't try to humanize per-month rules
const isLiteralOrList = (f: string) =>
/^\d+(,\d+)*$/.test(f) || /^\*$/.test(f);
if (!isLiteralOrList(minField) || !isLiteralOrList(hourField)) return null;
if (!isLiteralOrList(domField) || !isLiteralOrList(dowField)) return null;
// Star minutes/hours would mean "every minute" / "every hour" — we'd
// need a step-value handler ("*/15") to describe that cleanly, and
// that path is power-user territory. Bail to raw display.
if (minField === "*" || hourField === "*") return null;
const minutes = minField.split(",").map((n) => parseInt(n, 10));
const hours = hourField.split(",").map((n) => parseInt(n, 10));
if (minutes.length !== 1 || hours.length !== 1) return null;
if (
!Number.isFinite(minutes[0]) ||
!Number.isFinite(hours[0]) ||
hours[0] < 0 ||
hours[0] > 23 ||
minutes[0] < 0 ||
minutes[0] > 59
) {
return null;
}
const time = `${pad2(hours[0])}:${pad2(minutes[0])}`;
const domAll = domField === "*";
const dowAll = dowField === "*";
if (domAll && dowAll) {
return strings.dailyAt.replace("{time}", time);
}
if (domAll && !dowAll) {
const days = dowField
.split(",")
.map((n) => parseInt(n, 10))
.filter((n) => Number.isFinite(n) && n >= 0 && n <= 6) as Weekday[];
if (days.length === 0) return null;
const labels = days
.map((d) => strings.weekdaysShort[d])
.filter(Boolean)
.join(", ");
return strings.weeklyAt
.replace("{days}", labels)
.replace("{time}", time);
}
if (!domAll && dowAll) {
const dom = parseInt(domField, 10);
if (!Number.isFinite(dom) || dom < 1 || dom > 31) return null;
return strings.monthlyAt
.replace("{day}", strings.ordinal(dom))
.replace("{time}", time);
}
// Both day-of-month AND day-of-week set is unusual and cron's
// OR-semantics for that combo are confusing — fall back to raw.
return null;
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : String(n);
}
/** Format an ISO date for inline display. Drops the seconds + TZ
* suffix so the cron list stays compact. Falls back to the raw string
* if Date parsing fails. */
function formatIsoLocal(iso: string, includeSeconds: boolean): string {
const d = new Date(iso);
if (Number.isNaN(d.getTime())) return iso;
const yyyy = d.getFullYear();
const mm = pad2(d.getMonth() + 1);
const dd = pad2(d.getDate());
const hh = pad2(d.getHours());
const mi = pad2(d.getMinutes());
if (includeSeconds) {
return `${yyyy}-${mm}-${dd} ${hh}:${mi}:${pad2(d.getSeconds())}`;
}
return `${yyyy}-${mm}-${dd} ${hh}:${mi}`;
}
/** Convenience: build an English ordinal suffix ("1st", "2nd", "23rd").
* Most non-English locales should just return ``String(day)`` from
* their ``ordinal`` override. */
export function englishOrdinal(day: number): string {
const d = Math.floor(day);
if (!Number.isFinite(d) || d < 1) return String(day);
const lastTwo = d % 100;
if (lastTwo >= 11 && lastTwo <= 13) return `${d}th`;
switch (d % 10) {
case 1:
return `${d}st`;
case 2:
return `${d}nd`;
case 3:
return `${d}rd`;
default:
return `${d}th`;
}
}
+39 -36
View File
@@ -21,7 +21,6 @@ import { Button } from "@nous-research/ui/ui/components/button";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { Stats } from "@nous-research/ui/ui/components/stats";
import { Card, CardContent, CardHeader, CardTitle } from "@nous-research/ui/ui/components/card";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { usePageHeader } from "@/contexts/usePageHeader";
import { useI18n } from "@/i18n";
import { PluginSlot } from "@/plugins";
@@ -148,11 +147,17 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
</div>
<div className="flex items-center gap-4 font-mondwest normal-case text-xs text-muted-foreground">
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-[#ffe6cb]" />
<div
className="h-2.5 w-2.5"
style={{ backgroundColor: "var(--series-input-token)" }}
/>
{t.analytics.input}
</div>
<div className="flex items-center gap-1.5">
<div className="h-2.5 w-2.5 bg-emerald-500" />
<div
className="h-2.5 w-2.5"
style={{ backgroundColor: "var(--series-output-token)" }}
/>
{t.analytics.output}
</div>
</div>
@@ -192,13 +197,19 @@ function TokenBarChart({ daily }: { daily: AnalyticsDailyEntry[] }) {
</div>
<div
className="w-full bg-[#ffe6cb]/70"
style={{ height: Math.max(inputH, total > 0 ? 1 : 0) }}
className="w-full"
style={{
backgroundColor:
"color-mix(in srgb, var(--series-input-token) 70%, transparent)",
height: Math.max(inputH, total > 0 ? 1 : 0),
}}
/>
<div
className="w-full bg-emerald-500/70"
className="w-full"
style={{
backgroundColor:
"color-mix(in srgb, var(--series-output-token) 70%, transparent)",
height: Math.max(outputH, d.output_tokens > 0 ? 1 : 0),
}}
/>
@@ -261,12 +272,12 @@ function DailyTable({ daily }: { daily: AnalyticsDailyEntry[] }) {
{d.sessions}
</td>
<td className="text-right py-2 px-4">
<span className="text-[#ffe6cb]">
<span style={{ color: "var(--series-input-token)" }}>
{formatTokens(d.input_tokens)}
</span>
</td>
<td className="text-right py-2 pl-4">
<span className="text-emerald-400">
<span style={{ color: "var(--series-output-token)" }}>
{formatTokens(d.output_tokens)}
</span>
</td>
@@ -319,11 +330,11 @@ function ModelTable({ models }: { models: AnalyticsModelEntry[] }) {
{m.sessions}
</td>
<td className="text-right py-2 pl-4">
<span className="text-[#ffe6cb]">
<span style={{ color: "var(--series-input-token)" }}>
{formatTokens(m.input_tokens)}
</span>
{" / "}
<span className="text-emerald-400">
<span style={{ color: "var(--series-output-token)" }}>
{formatTokens(m.output_tokens)}
</span>
</td>
@@ -427,14 +438,24 @@ export default function AnalyticsPage() {
}, [days, showTokens]);
useLayoutEffect(() => {
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
// Period selector + refresh both live in afterTitle so the controls
// sit immediately next to the page title instead of being pinned to
// the far-right `end` slot. The active period is conveyed by the
// filled (non-outlined) button — no redundant period badge.
setAfterTitle(
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{periodLabel}
</Badge>
{showTokens !== false && (
showTokens === false ? null : (
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
<Button
type="button"
ghost
@@ -446,28 +467,10 @@ export default function AnalyticsPage() {
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
)}
</span>,
);
setEnd(
showTokens === false ? null : (
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
>
{p.label}
</Button>
))}
</div>
</div>
),
);
setEnd(null);
return () => {
setAfterTitle(null);
setEnd(null);
+84 -46
View File
@@ -8,6 +8,17 @@ import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { api } from "@/lib/api";
import type { CronJob, ProfileInfo } from "@/lib/api";
import { DeleteConfirmDialog } from "@/components/DeleteConfirmDialog";
import {
DEFAULT_SCHEDULE_STATE,
ScheduleBuilder,
} from "@/components/ScheduleBuilder";
import {
buildScheduleString,
describeSchedule,
englishOrdinal,
type ScheduleBuilderState,
type ScheduleDescribeStrings,
} from "@/lib/schedule";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
import { useModalBehavior } from "@/hooks/useModalBehavior";
@@ -57,12 +68,20 @@ function getJobTitle(job: CronJob): string {
return job.id || "Cron job";
}
function getJobScheduleDisplay(job: CronJob): string {
return (
asText(job.schedule_display) ||
asText(job.schedule?.display) ||
asText(job.schedule?.expr) ||
"—"
function getJobScheduleDisplay(
job: CronJob,
strings: ScheduleDescribeStrings,
): string {
// Prefer a structured render so cron expressions like
// ``30 14 * * 1,3,5`` surface as "Weekly on Mon, Wed, Fri at 14:30"
// in the list instead of the raw five-field gibberish. Falls back
// through the existing chain (``schedule_display`` from the backend,
// then the structured ``display`` field, then the raw ``expr``) so
// legacy job rows still render *something* meaningful.
return describeSchedule(
job.schedule,
asText(job.schedule_display) || asText(job.schedule?.display),
strings,
);
}
@@ -102,13 +121,35 @@ export default function CronPage() {
const [selectedProfile, setSelectedProfile] = useState("all");
const [loading, setLoading] = useState(true);
const { toast, showToast } = useToast();
const { t } = useI18n();
const { t, locale } = useI18n();
const { setEnd } = usePageHeader();
// Translation surface for the human-readable schedule describer.
// English ordinals are a special case ("1st", "2nd", "23rd"); every
// other locale falls back to the plain numeric form, which avoids
// shipping incorrect grammar (e.g. naive "1th"/"2th" suffixes that
// don't exist in most languages).
//
// Built inline (not memoized) — the cron page renders a small job
// list, this is single-digit microseconds, and a useMemo here would
// just add boilerplate.
const scheduleDescribeStrings: ScheduleDescribeStrings = {
...t.cron.scheduleDescribe,
weekdaysShort: t.cron.scheduleModes.weekdaysShort,
ordinal: locale === "en" ? englishOrdinal : (n: number) => String(n),
};
// New job modal state
const [createModalOpen, setCreateModalOpen] = useState(false);
const [prompt, setPrompt] = useState("");
const [schedule, setSchedule] = useState("");
// The schedule is now constructed via the ScheduleBuilder; we keep
// the full builder state so flipping between modes during edit
// doesn't erase the user's intermediate inputs. The actual string
// sent to the backend is derived via ``buildScheduleString`` at
// submit time.
const [scheduleState, setScheduleState] = useState<ScheduleBuilderState>(
DEFAULT_SCHEDULE_STATE,
);
const [name, setName] = useState("");
const closeCreateModal = useCallback(() => setCreateModalOpen(false), []);
const createModalRef = useModalBehavior({
@@ -161,8 +202,10 @@ export default function CronPage() {
loadJobs();
}, [loadJobs]);
const scheduleString = buildScheduleString(scheduleState);
const handleCreate = async () => {
if (!prompt.trim() || !schedule.trim()) {
if (!prompt.trim() || !scheduleString) {
showToast(`${t.cron.prompt} & ${t.cron.schedule} required`, "error");
return;
}
@@ -171,7 +214,7 @@ export default function CronPage() {
await api.createCronJob(
{
prompt: prompt.trim(),
schedule: schedule.trim(),
schedule: scheduleString,
name: name.trim() || undefined,
deliver,
},
@@ -179,7 +222,7 @@ export default function CronPage() {
);
showToast(t.common.create + " ✓", "success");
setPrompt("");
setSchedule("");
setScheduleState(DEFAULT_SCHEDULE_STATE);
setName("");
setDeliver("local");
setCreateModalOpen(false);
@@ -392,41 +435,34 @@ export default function CronPage() {
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="grid gap-2">
<Label htmlFor="cron-schedule">{t.cron.schedule}</Label>
<Input
id="cron-schedule"
placeholder={t.cron.schedulePlaceholder}
value={schedule}
onChange={(e) => setSchedule(e.target.value)}
/>
</div>
<ScheduleBuilder
value={scheduleState}
onChange={setScheduleState}
/>
<div className="grid gap-2">
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
<Select
id="cron-deliver"
value={deliver}
onValueChange={(v) => setDeliver(v)}
>
<SelectOption value="local">
{t.cron.delivery.local}
</SelectOption>
<SelectOption value="telegram">
{t.cron.delivery.telegram}
</SelectOption>
<SelectOption value="discord">
{t.cron.delivery.discord}
</SelectOption>
<SelectOption value="slack">
{t.cron.delivery.slack}
</SelectOption>
<SelectOption value="email">
{t.cron.delivery.email}
</SelectOption>
</Select>
</div>
<div className="grid gap-2">
<Label htmlFor="cron-deliver">{t.cron.deliverTo}</Label>
<Select
id="cron-deliver"
value={deliver}
onValueChange={(v) => setDeliver(v)}
>
<SelectOption value="local">
{t.cron.delivery.local}
</SelectOption>
<SelectOption value="telegram">
{t.cron.delivery.telegram}
</SelectOption>
<SelectOption value="discord">
{t.cron.delivery.discord}
</SelectOption>
<SelectOption value="slack">
{t.cron.delivery.slack}
</SelectOption>
<SelectOption value="email">
{t.cron.delivery.email}
</SelectOption>
</Select>
</div>
<div className="flex justify-end">
@@ -617,7 +653,9 @@ export default function CronPage() {
</p>
)}
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="font-mono">{getJobScheduleDisplay(job)}</span>
<span className="font-mono-ui">
{getJobScheduleDisplay(job, scheduleDescribeStrings)}
</span>
<span>
{t.cron.last}: {formatTime(job.last_run_at)}
</span>
+39 -33
View File
@@ -95,11 +95,17 @@ function TokenBar({
const total = input + output + cacheRead + reasoning;
if (total === 0) return null;
const segments = [
{ value: cacheRead, color: "bg-blue-400/60", dotColor: "bg-blue-400", label: "Cache Read" },
{ value: reasoning, color: "bg-purple-400/60", dotColor: "bg-purple-400", label: "Reasoning" },
{ value: input, color: "bg-[#ffe6cb]/70", dotColor: "bg-[#ffe6cb]", label: "Input" },
{ value: output, color: "bg-emerald-500/70", dotColor: "bg-emerald-500", label: "Output" },
// Segments carry a CSS color value (hex or `var(--token)`) rather than
// a Tailwind class so the input/output series can pick up the active
// theme's `--series-*-token` vars — see `themes/types.ts`
// `ThemeSeriesColors`. The /60/70 fade on the bar is applied via
// color-mix on the same value so themes don't need to ship two
// separate hex literals.
const segments: Array<{ color: string; label: string; value: number }> = [
{ value: cacheRead, color: "#60a5fa", label: "Cache Read" }, // tailwind blue-400
{ value: reasoning, color: "#c084fc", label: "Reasoning" }, // tailwind purple-400
{ value: input, color: "var(--series-input-token)", label: "Input" },
{ value: output, color: "var(--series-output-token)", label: "Output" },
].filter((s) => s.value > 0);
return (
@@ -109,8 +115,11 @@ function TokenBar({
{segments.map((s, i) => (
<div
key={i}
className={`${s.color} relative flex items-center transition-all duration-300`}
style={{ width: `${(s.value / total) * 100}%` }}
className="relative flex items-center transition-all duration-300"
style={{
backgroundColor: `color-mix(in srgb, ${s.color} 70%, transparent)`,
width: `${(s.value / total) * 100}%`,
}}
>
{/* Stepped fill pattern overlay */}
<div
@@ -128,7 +137,10 @@ function TokenBar({
<div className="flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-text-secondary">
{segments.map((s, i) => (
<span key={i} className="flex items-center gap-1">
<span className={`inline-block h-1.5 w-1.5 rounded-full ${s.dotColor}`} />
<span
className="inline-block h-1.5 w-1.5 rounded-full"
style={{ backgroundColor: s.color }}
/>
{s.label} {formatTokens(s.value)}
</span>
))}
@@ -152,7 +164,7 @@ function CapabilityBadges({
return (
<div className="flex flex-wrap items-center gap-1.5">
{capabilities.supports_tools && (
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-1.5 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
<span className="inline-flex items-center gap-1 bg-success/10 px-1.5 py-0.5 text-xs font-medium text-success">
<Wrench className="h-2.5 w-2.5" /> Tools
</span>
)}
@@ -818,13 +830,24 @@ export default function ModelsPage() {
}, []);
useLayoutEffect(() => {
const periodLabel =
PERIODS.find((p) => p.days === days)?.label ?? `${days}d`;
// Period selector + refresh both live in afterTitle so the controls
// sit immediately next to the page title instead of being pinned to
// the far-right `end` slot. The active period is conveyed by the
// filled (non-outlined) button — no redundant period badge.
setAfterTitle(
<span className="flex items-center gap-1.5">
<Badge tone="secondary" className="text-xs">
{periodLabel}
</Badge>
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
className="uppercase"
>
{p.label}
</Button>
))}
<Button
type="button"
ghost
@@ -836,26 +859,9 @@ export default function ModelsPage() {
>
{loading ? <Spinner /> : <RefreshCw />}
</Button>
</span>,
);
setEnd(
<div className="flex w-full min-w-0 flex-wrap items-center justify-start gap-2 sm:justify-end sm:gap-2">
<div className="flex flex-wrap items-center gap-1.5">
{PERIODS.map((p) => (
<Button
key={p.label}
type="button"
size="sm"
outlined={days !== p.days}
onClick={() => setDays(p.days)}
className="uppercase"
>
{p.label}
</Button>
))}
</div>
</div>,
);
setEnd(null);
return () => {
setAfterTitle(null);
setEnd(null);
+384 -23
View File
@@ -23,6 +23,7 @@ import {
Hash,
X,
Play,
Eraser,
Download,
Pencil,
Check,
@@ -41,6 +42,7 @@ import { Markdown } from "@/components/Markdown";
import { PlatformsCard } from "@/components/PlatformsCard";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { Button } from "@nous-research/ui/ui/components/button";
import { Checkbox } from "@nous-research/ui/ui/components/checkbox";
import { ListItem } from "@nous-research/ui/ui/components/list-item";
import { Segmented } from "@nous-research/ui/ui/components/segmented";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
@@ -273,22 +275,14 @@ function SessionRow({
snippet,
searchQuery,
isExpanded,
isSelected,
onToggle,
onSelectClick,
onDelete,
onRename,
onExport,
resumeInChatEnabled,
}: {
session: SessionInfo;
snippet?: string;
searchQuery?: string;
isExpanded: boolean;
onToggle: () => void;
onDelete: () => void;
onRename: (id: string, title: string) => Promise<void>;
onExport: (id: string) => void;
resumeInChatEnabled: boolean;
}) {
}: SessionRowProps) {
const [messages, setMessages] = useState<SessionMessage[] | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -400,18 +394,44 @@ function SessionRow({
</>
);
// Selected rows get a stronger left-edge accent + tinted background so the
// selection state is unambiguous even when scrolling past the bulk-action
// bar at the top. Beat the is_active styling — explicit user selection
// takes priority over "this session is live".
const containerClasses = isSelected
? "border-primary/40 bg-primary/[0.06]"
: session.is_active
? "border-success/30 bg-success/[0.03]"
: "border-border";
// Clicking the checkbox must NOT toggle row expansion; selection and
// expansion are independent gestures. We bind ``onClick`` directly on
// the Checkbox (which Radix forwards to its underlying ``<button
// role=checkbox>``) so the event carries the real ``shiftKey`` state
// for range-select AND so keyboard activation (Space on the focused
// checkbox) toggles selection via the same code path — the browser
// synthesises a click on <button> for Space, so one handler covers
// mouse + keyboard cleanly.
const handleSelectClick = (e: React.MouseEvent) => {
e.stopPropagation();
onSelectClick(e);
};
return (
<div
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${
session.is_active
? "border-success/30 bg-success/[0.03]"
: "border-border"
}`}
className={`max-w-full min-w-0 overflow-hidden border transition-colors ${containerClasses}`}
>
<div
className="flex cursor-pointer items-start gap-3 p-3 transition-colors hover:bg-secondary/30"
onClick={onToggle}
>
<span className="flex shrink-0 items-center pt-0.5">
<Checkbox
checked={isSelected}
onClick={handleSelectClick}
aria-label={t.sessions.selectSession}
/>
</span>
<div className={`shrink-0 pt-0.5 ${sourceInfo.color}`}>
<SourceIcon className="h-4 w-4" />
</div>
@@ -606,6 +626,30 @@ export default function SessionsPage() {
const [status, setStatus] = useState<StatusResponse | null>(null);
const [overviewSessions, setOverviewSessions] = useState<SessionInfo[]>([]);
const [view, setView] = useState<SessionsView>("overview");
// Count of empty (no-message, ended, non-archived) sessions across the
// entire DB, populated by /api/sessions/empty/count. Used to:
// • hide the "Delete empty" button when there's nothing to clean up
// • show "(N)" alongside the label
// • surface the count in the confirm dialog body
// Refreshed on mount, after single-session deletes, and after the bulk
// delete itself — none of those code paths can update the global empty
// count from local state alone (per-page list != global DB count).
const [emptyCount, setEmptyCount] = useState(0);
const [deleteEmptyOpen, setDeleteEmptyOpen] = useState(false);
const [deletingEmpty, setDeletingEmpty] = useState(false);
// Bulk-select-then-delete state. ``selectedIds`` is a Set so per-row
// checkbox toggles and ``has()`` lookups are O(1); we wrap mutations
// in a fresh Set so React notices the change (mutating in place
// wouldn't trigger a re-render).
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
// Index of the last row whose checkbox was clicked WITHOUT shift,
// resolved against the currently visible (post-search) ``filtered``
// list. Used as the anchor for shift-click range select — matches the
// Gmail / Notion / file-explorer convention. ``null`` means "no
// anchor yet", in which case shift-click degrades to a plain toggle.
const lastClickedIndexRef = useRef<number | null>(null);
const [deleteSelectedOpen, setDeleteSelectedOpen] = useState(false);
const [deletingSelected, setDeletingSelected] = useState(false);
const [stats, setStats] = useState<SessionStoreStats | null>(null);
const [pruneOpen, setPruneOpen] = useState(false);
const [pruneDays, setPruneDays] = useState("90");
@@ -616,6 +660,18 @@ export default function SessionsPage() {
const { activeAction, actionStatus, dismissLog } = useSystemActions();
const resumeInChatEnabled = isDashboardEmbeddedChatEnabled();
const refreshEmptyCount = useCallback(() => {
api
.getEmptySessionsCount()
.then((r) => setEmptyCount(r.count))
.catch(() => {});
}, []);
const clearSelection = useCallback(() => {
setSelectedIds(new Set());
lastClickedIndexRef.current = null;
}, []);
useLayoutEffect(() => {
if (loading) {
setAfterTitle(null);
@@ -673,7 +729,8 @@ export default function SessionsPage() {
useEffect(() => {
loadSessions(page);
}, [loadSessions, page]);
refreshEmptyCount();
}, [loadSessions, page, refreshEmptyCount]);
useEffect(() => {
const loadOverview = () => {
@@ -696,6 +753,36 @@ export default function SessionsPage() {
if (el) el.scrollTop = el.scrollHeight;
}, [actionStatus?.lines]);
// Wrapped setters that ALSO clear the bulk selection. The user's
// mental model is "I'm selecting what I can see" — carrying a
// selection across a page change, search input, or view switch
// would arm invisible rows for deletion, which is the exact footgun
// the confirm dialog can't catch. Doing this at the call sites
// instead of in a ``useEffect`` keeps us out of the
// react-hooks/set-state-in-effect lint trap and the cascading
// re-render it warns about.
const goToPage = useCallback(
(p: number) => {
setPage(p);
clearSelection();
},
[clearSelection],
);
const updateSearch = useCallback(
(value: string) => {
setSearch(value);
clearSelection();
},
[clearSelection],
);
const switchView = useCallback(
(next: SessionsView) => {
setView(next);
clearSelection();
},
[clearSelection],
);
// Debounced FTS search
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
@@ -728,6 +815,18 @@ export default function SessionsPage() {
setSessions((prev) => prev.filter((s) => s.id !== id));
setTotal((prev) => prev - 1);
if (expandedId === id) setExpandedId(null);
// Drop the deleted ID from any active bulk-select set — it
// can't bulk-delete a row that's already gone.
setSelectedIds((prev) => {
if (!prev.has(id)) return prev;
const next = new Set(prev);
next.delete(id);
return next;
});
// A single-session delete might have been an empty one — re-fetch
// the global empty count so the button hides itself / its badge
// ticks down without waiting for the next page navigation.
refreshEmptyCount();
showToast(t.sessions.sessionDeleted, "success");
loadStats();
} catch {
@@ -737,6 +836,7 @@ export default function SessionsPage() {
},
[
expandedId,
refreshEmptyCount,
showToast,
loadStats,
t.sessions.sessionDeleted,
@@ -745,6 +845,140 @@ export default function SessionsPage() {
),
});
/** Toggle one row's selection. When ``event.shiftKey`` is true AND we
* have a previous anchor, every row between the anchor and the
* current index (inclusive) is set to the current row's NEW state —
* matches Gmail/Notion/file-explorer semantics. ``visibleList`` must
* be the currently rendered list (post-search), since indices are
* resolved against what the user is actually looking at.
*/
const handleSelectClick = useCallback(
(event: React.MouseEvent, index: number, visibleList: SessionInfo[]) => {
const id = visibleList[index]?.id;
if (!id) return;
setSelectedIds((prev) => {
const next = new Set(prev);
const wasSelected = next.has(id);
const willSelect = !wasSelected;
const anchor = lastClickedIndexRef.current;
// Shift-click extends the selection from the anchor to here.
// Skip if there's no anchor or the anchor is outside the
// visible list — in those cases fall through to a plain toggle
// (the click also resets the anchor below).
if (event.shiftKey && anchor !== null && anchor < visibleList.length) {
const [lo, hi] =
anchor <= index ? [anchor, index] : [index, anchor];
for (let i = lo; i <= hi; i++) {
const rowId = visibleList[i]?.id;
if (!rowId) continue;
if (willSelect) next.add(rowId);
else next.delete(rowId);
}
} else if (willSelect) {
next.add(id);
} else {
next.delete(id);
}
return next;
});
// Always update the anchor to the most recent click — even when
// it was a shift-click that extended a range, the user's next
// shift-click should anchor from here, not from two steps back.
lastClickedIndexRef.current = index;
},
[],
);
const selectAllOnPage = useCallback((visibleList: SessionInfo[]) => {
setSelectedIds((prev) => {
const next = new Set(prev);
for (const s of visibleList) next.add(s.id);
return next;
});
}, []);
const handleDeleteSelected = useCallback(async () => {
const ids = Array.from(selectedIds);
if (ids.length === 0) {
setDeleteSelectedOpen(false);
return;
}
setDeletingSelected(true);
try {
const resp = await api.bulkDeleteSessions(ids);
showToast(
t.sessions.selectedSessionsDeleted.replace(
"{count}",
String(resp.deleted),
),
"success",
);
setDeleteSelectedOpen(false);
// Drop deleted rows out of the visible list immediately rather
// than waiting for the reload. The reload still runs so total /
// pagination stays correct, and so any rows the reload pulls in
// from later pages render in place.
const deletedSet = new Set(ids);
setSessions((prev) => prev.filter((s) => !deletedSet.has(s.id)));
setTotal((prev) => Math.max(0, prev - resp.deleted));
if (expandedId && deletedSet.has(expandedId)) setExpandedId(null);
clearSelection();
loadSessions(page);
refreshEmptyCount();
} catch {
showToast(t.sessions.failedToDeleteSelected, "error");
} finally {
setDeletingSelected(false);
}
}, [
clearSelection,
expandedId,
loadSessions,
page,
refreshEmptyCount,
selectedIds,
showToast,
t.sessions.failedToDeleteSelected,
t.sessions.selectedSessionsDeleted,
]);
const handleDeleteEmpty = useCallback(async () => {
setDeletingEmpty(true);
try {
const resp = await api.deleteEmptySessions();
// Show count in the toast so users get confirmation of the actual
// number removed (which may differ slightly from `emptyCount` if a
// session entered/left the "empty" set between the count fetch and
// the delete — e.g. an active session just ended without sending
// any messages).
showToast(
t.sessions.emptySessionsDeleted.replace(
"{count}",
String(resp.deleted),
),
"success",
);
setDeleteEmptyOpen(false);
// Reload the current page so any newly-vanished empty sessions
// drop out of the visible list, and re-fetch the empty count so
// the button hides itself.
loadSessions(page);
refreshEmptyCount();
} catch {
showToast(t.sessions.failedToDeleteEmpty, "error");
} finally {
setDeletingEmpty(false);
}
}, [
loadSessions,
page,
refreshEmptyCount,
showToast,
t.sessions.emptySessionsDeleted,
t.sessions.failedToDeleteEmpty,
]);
const handleRename = useCallback(
async (id: string, title: string) => {
try {
@@ -898,6 +1132,33 @@ export default function SessionsPage() {
loading={sessionDelete.isDeleting}
/>
<DeleteConfirmDialog
open={deleteEmptyOpen}
onCancel={() => setDeleteEmptyOpen(false)}
onConfirm={handleDeleteEmpty}
title={t.sessions.deleteEmptyConfirmTitle}
description={t.sessions.deleteEmptyConfirmMessage.replace(
"{count}",
String(emptyCount),
)}
loading={deletingEmpty}
/>
<DeleteConfirmDialog
open={deleteSelectedOpen}
onCancel={() => setDeleteSelectedOpen(false)}
onConfirm={handleDeleteSelected}
title={t.sessions.deleteSelectedConfirmTitle.replace(
"{count}",
String(selectedIds.size),
)}
description={t.sessions.deleteSelectedConfirmMessage.replace(
"{count}",
String(selectedIds.size),
)}
loading={deletingSelected}
/>
<Dialog
open={pruneOpen}
onOpenChange={(open) => {
@@ -1084,7 +1345,7 @@ export default function SessionsPage() {
className="w-fit shrink-0"
size="md"
value={view}
onChange={setView}
onChange={switchView}
options={[
{ value: "overview", label: t.sessions.overview },
{ value: "list", label: t.sessions.history },
@@ -1102,7 +1363,7 @@ export default function SessionsPage() {
<Input
placeholder={t.sessions.searchPlaceholder}
value={search}
onChange={(e) => setSearch(e.target.value)}
onChange={(e) => updateSearch(e.target.value)}
className="h-8 py-0 pr-7 pl-8 text-xs leading-none"
/>
{search && (
@@ -1110,7 +1371,7 @@ export default function SessionsPage() {
ghost
size="xs"
className="absolute right-1.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
onClick={() => setSearch("")}
onClick={() => updateSearch("")}
aria-label={t.common.clear}
>
<X />
@@ -1118,6 +1379,23 @@ export default function SessionsPage() {
)}
</div>
)}
{showList && emptyCount > 0 && !isSearching && (
<Button
outlined
destructive
size="sm"
className="shrink-0"
onClick={() => setDeleteEmptyOpen(true)}
aria-label={t.sessions.deleteEmpty}
title={t.sessions.deleteEmpty}
>
<Eraser className="h-3.5 w-3.5" />
<span className="font-mondwest normal-case text-xs">
{t.sessions.deleteEmpty} ({emptyCount})
</span>
</Button>
)}
</div>
{showPagination && (
@@ -1126,12 +1404,77 @@ export default function SessionsPage() {
className="shrink-0 sm:ml-auto"
page={page}
total={total}
onPageChange={setPage}
onPageChange={goToPage}
/>
)}
</div>
) : null}
{showList && selectedIds.size > 0 && (
<div
className="flex flex-wrap items-center gap-2 border border-primary/30 bg-primary/[0.06] px-3 py-2"
role="region"
aria-label={t.sessions.selectedCount.replace(
"{count}",
String(selectedIds.size),
)}
>
<span className="font-mondwest normal-case text-xs text-primary tabular-nums">
{t.sessions.selectedCount.replace(
"{count}",
String(selectedIds.size),
)}
</span>
{filtered.some((s) => !selectedIds.has(s.id)) && (
<Button
ghost
size="sm"
onClick={() => selectAllOnPage(filtered)}
aria-label={t.sessions.selectAllOnPage}
title={t.sessions.selectAllOnPage}
>
<span className="font-mondwest normal-case text-xs">
{t.sessions.selectAllOnPage}
</span>
</Button>
)}
<Button
ghost
size="sm"
onClick={clearSelection}
aria-label={t.sessions.clearSelection}
title={t.sessions.clearSelection}
>
<span className="font-mondwest normal-case text-xs">
{t.sessions.clearSelection}
</span>
</Button>
<Button
outlined
destructive
size="sm"
className="ml-auto"
onClick={() => setDeleteSelectedOpen(true)}
aria-label={t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
title={t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
>
<Trash2 className="h-3.5 w-3.5" />
<span className="font-mondwest normal-case text-xs">
{t.sessions.deleteSelected.replace(
"{count}",
String(selectedIds.size),
)}
</span>
</Button>
</div>
)}
{showList ? (
filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 text-muted-foreground">
@@ -1148,16 +1491,20 @@ export default function SessionsPage() {
) : (
<>
<div className="flex min-w-0 flex-col gap-1.5">
{filtered.map((s) => (
{filtered.map((s, index) => (
<SessionRow
key={s.id}
session={s}
snippet={snippetMap.get(s.id)}
searchQuery={search || undefined}
isExpanded={expandedId === s.id}
isSelected={selectedIds.has(s.id)}
onToggle={() =>
setExpandedId((prev) => (prev === s.id ? null : s.id))
}
onSelectClick={(event) =>
handleSelectClick(event, index, filtered)
}
onDelete={() => sessionDelete.requestDelete(s.id)}
onRename={handleRename}
onExport={handleExport}
@@ -1170,7 +1517,7 @@ export default function SessionsPage() {
<SessionsPagination
page={page}
total={total}
onPageChange={setPage}
onPageChange={goToPage}
/>
)}
</>
@@ -1238,6 +1585,20 @@ export default function SessionsPage() {
);
}
interface SessionRowProps {
isExpanded: boolean;
isSelected: boolean;
onDelete: () => void;
onExport: (id: string) => void;
onRename: (id: string, title: string) => Promise<void>;
onSelectClick: (event: React.MouseEvent) => void;
onToggle: () => void;
resumeInChatEnabled: boolean;
searchQuery?: string;
session: SessionInfo;
snippet?: string;
}
interface SessionsPaginationProps {
className?: string;
compact?: boolean;
+20 -30
View File
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router-dom";
import {
Activity,
Brain,
@@ -21,12 +22,12 @@ import {
} from "lucide-react";
import { Badge } from "@nous-research/ui/ui/components/badge";
import { Button } from "@nous-research/ui/ui/components/button";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Spinner } from "@nous-research/ui/ui/components/spinner";
import { H2 } from "@nous-research/ui/ui/components/typography/h2";
import { Card, CardContent } from "@nous-research/ui/ui/components/card";
import { Input } from "@nous-research/ui/ui/components/input";
import { Label } from "@nous-research/ui/ui/components/label";
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
import { Toast } from "@nous-research/ui/ui/components/toast";
import { useToast } from "@nous-research/ui/hooks/use-toast";
import { useConfirmDelete } from "@nous-research/ui/hooks/use-confirm-delete";
@@ -236,16 +237,9 @@ export default function SystemPage() {
};
// ── Memory ─────────────────────────────────────────────────────────
const setMemoryProvider = async (provider: string) => {
try {
await api.setMemoryProvider(provider);
showToast(`Memory provider: ${provider || "built-in only"}`, "success");
loadAll();
} catch (e) {
showToast(`Failed to set provider: ${e}`, "error");
}
};
// Memory provider selection lives on the /plugins page now (see the
// read-only display + link below); the dropdown was intentionally
// dropped from this card during the admin-panel refresh.
const memoryReset = useConfirmDelete({
onDelete: useCallback(
async (target: string) => {
@@ -748,26 +742,22 @@ export default function SystemPage() {
</H2>
<Card>
<CardContent className="flex flex-col gap-4 py-4">
<div className="grid gap-2 max-w-sm">
<Label htmlFor="mem-provider">External provider</Label>
<Select
id="mem-provider"
value={memory?.active || ""}
onValueChange={setMemoryProvider}
>
<SelectOption value="">Built-in only</SelectOption>
{(memory?.providers ?? []).map((p) => (
<SelectOption key={p.name} value={p.name}>
{p.name}
{p.configured ? " (configured)" : ""}
</SelectOption>
))}
</Select>
<p className="text-xs text-muted-foreground">
Set up a new provider's credentials with{" "}
<span className="font-mono">hermes memory setup</span>.
</p>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>
External provider:{" "}
<span className="font-mono text-foreground">
{memory?.active || "built-in only"}
</span>
</span>
<Link to="/plugins" className="underline">
Change in Plugins
</Link>
<span className="ml-auto">
New credentials:{" "}
<span className="font-mono">hermes memory setup</span>
</span>
</div>
<div className="flex flex-wrap items-center gap-3 border-t border-border pt-3">
<span className="text-xs text-muted-foreground">
Built-in files MEMORY.md:{" "}
+67 -4
View File
@@ -19,6 +19,7 @@ import type {
ThemeLayoutVariant,
ThemeListEntry,
ThemePalette,
ThemeSeriesColors,
ThemeTypography,
} from "./types";
import { api } from "@/lib/api";
@@ -27,6 +28,21 @@ import { api } from "@/lib/api";
* a visible flash of the default palette on theme-overridden installs. */
const STORAGE_KEY = "hermes-dashboard-theme";
/** Renames of built-in theme keys we've shipped previously. Without this,
* users who saved one of the old names in localStorage (or had it
* persisted server-side) would silently fall back to `defaultTheme`
* because the lookup in `resolveTheme` no longer finds the stale key.
* Keep entries here until enough release cycles have passed that we can
* reasonably assume nobody still has the old value persisted. */
const THEME_NAME_ALIASES: Record<string, string> = {
// Renamed during the LENS_5I port + Nous-blue rebrand.
"lens-5i": "nous-blue",
};
function migrateThemeName(name: string): string {
return THEME_NAME_ALIASES[name] ?? name;
}
/** Tracks fontUrls we've already injected so multiple theme switches don't
* pile up <link> tags. Keyed by URL. */
const INJECTED_FONT_URLS = new Set<string>();
@@ -126,6 +142,30 @@ function overrideVars(
return out;
}
/** Map data-series accents to their CSS vars. Themes omit either field to
* inherit the `:root` default from `index.css`; when omitted we also
* proactively clear any leftover value from a previous theme so switches
* don't carry stale colors. */
const SERIES_KEY_TO_VAR: Record<keyof ThemeSeriesColors, string> = {
inputTokenAccent: "--series-input-token",
outputTokenAccent: "--series-output-token",
};
const ALL_SERIES_VARS = Object.values(SERIES_KEY_TO_VAR);
function seriesColorVars(
series: ThemeSeriesColors | undefined,
): Record<string, string> {
if (!series) return {};
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(series)) {
if (!value) continue;
const cssVar = SERIES_KEY_TO_VAR[key as keyof ThemeSeriesColors];
if (cssVar) out[cssVar] = value;
}
return out;
}
// ---------------------------------------------------------------------------
// Asset + component-style + layout variant vars
// ---------------------------------------------------------------------------
@@ -268,6 +308,12 @@ function applyTheme(theme: DashboardTheme) {
for (const cssVar of ALL_OVERRIDE_VARS) {
root.style.removeProperty(cssVar);
}
// Same clear-then-set for series colors so a theme that defines them
// (e.g. Nous Blue) doesn't leave its values behind when the user
// switches to a theme that inherits the `:root` defaults.
for (const cssVar of ALL_SERIES_VARS) {
root.style.removeProperty(cssVar);
}
// Clear dynamic (asset/component) vars from the previous theme so the
// new one starts clean — otherwise stale notched clip-paths, hero URLs,
// etc. would bleed across theme switches.
@@ -287,6 +333,7 @@ function applyTheme(theme: DashboardTheme) {
...typographyVars(theme.typography),
...layoutVars(theme.layout),
...overrideVars(theme.colorOverrides),
...seriesColorVars(theme.seriesColors),
...assetMap,
...componentMap,
};
@@ -313,7 +360,14 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
/** Name of the currently active theme (built-in id or user YAML name). */
const [themeName, setThemeName] = useState<string>(() => {
if (typeof window === "undefined") return "default";
return window.localStorage.getItem(STORAGE_KEY) ?? "default";
const stored = window.localStorage.getItem(STORAGE_KEY) ?? "default";
const migrated = migrateThemeName(stored);
// Write the migrated name back so future reads converge on the new
// key and we eventually retire the alias entry.
if (migrated !== stored) {
window.localStorage.setItem(STORAGE_KEY, migrated);
}
return migrated;
});
/** All selectable themes (shown in the picker). Starts with just the
@@ -377,9 +431,18 @@ export function ThemeProvider({ children }: { children: ReactNode }) {
}
if (Object.keys(defs).length > 0) setUserThemeDefs(defs);
}
if (resp.active && resp.active !== themeName) {
setThemeName(resp.active);
window.localStorage.setItem(STORAGE_KEY, resp.active);
if (resp.active) {
const migratedActive = migrateThemeName(resp.active);
if (migratedActive !== themeName) {
setThemeName(migratedActive);
window.localStorage.setItem(STORAGE_KEY, migratedActive);
}
// If the server is still persisting the stale key, push the
// migrated value back so it converges too — otherwise every
// future page load would re-trigger this branch.
if (migratedActive !== resp.active) {
api.setTheme(migratedActive).catch(() => {});
}
}
})
.catch(() => {});
+95
View File
@@ -184,6 +184,100 @@ export const roseTheme: DashboardTheme = {
},
};
/**
* Nous Blue the inverted "light mode" Hermes look, ported from the
* LENS_5I overlay preset in `@nous-research/ui`.
*
* Unlike the other built-ins (which paint dark color directly on the
* canvas), this theme relies on `<Backdrop />`'s foreground inversion
* layer: an opaque white sheet at z-200 with `mix-blend-mode: difference`
* that flips the entire stack below it. Authoring colors stay dark
* (`#170d02` brown background, `#FFAC02` orange midground), and the
* inversion converts them to their visual complements at paint time
* the orange midground reads as #0053FD Nous-blue on screen, against a
* cream `#E8F2FD` canvas.
*
* Note on bg blend mode: the DS Lens uses `multiply` for LENS_5I because
* nousnet-web's <body> is white; hermes-agent's App root is `bg-black`,
* so we leave the bg layer's blend mode at the `difference` default
* `difference(#170d02, #000)` passes the bg through unchanged, and the
* subsequent FG-difference layer then inverts it to cream. Using
* `multiply` here would collapse the bg to pure black against the
* `bg-black` root and produce a plain-white canvas instead of the
* intended cream-blue.
*
* Source of truth for the palette: `design-language/src/ui/components/
* overlays/lens.ts` (LENS_5I export).
*/
export const nousBlueTheme: DashboardTheme = {
name: "nous-blue",
label: "Nous Blue",
description: "Light mode — vivid Nous-blue accents on cream canvas",
palette: {
background: { hex: "#170d02", alpha: 1 },
midground: { hex: "#FFAC02", alpha: 1 },
foreground: { hex: "#FFFFFF", alpha: 1 },
// Same warm-amber as nousnet-web's overlay glow; after the FG
// inversion it reads as a cool ultraviolet vignette in the top-left.
warmGlow: "rgba(255, 172, 2, 0.18)",
// Noise sits above the FG inversion and is NOT flipped, so a softer
// multiplier keeps it from speckling over the bright post-inversion
// canvas.
noiseOpacity: 0.4,
},
typography: DEFAULT_TYPOGRAPHY,
layout: DEFAULT_LAYOUT,
// Inverted page: the embedded terminal is below the FG layer too, so
// a `#000000` source paints as visual white — i.e. a proper light-mode
// terminal pane. xterm picks lighter palette colors against the "black"
// canvas, which then read as dark text on screen post-inversion.
terminalBackground: "#000000",
componentStyles: {
backdrop: {
// Lower than LENS_5I.Lens.fillerOpacity (0.06). The filler texture
// gets amplified post-inversion: small variations against the deep
// `#170d02` source bg are barely visible, but those same variations
// against the bright `#E8F2FD` post-inversion canvas read as a
// heavy cloud/marble pattern — especially on near-empty pages
// (loading spinners, blank states). 0.02 keeps subtle grain
// without overwhelming the canvas.
fillerOpacity: "0.02",
},
},
// Pre-invert absolute-hex tokens so they read as their familiar colors
// through the FG difference layer. e.g. source #04D3C9 (cyan) is what
// gets painted, and `255 - channel` flips it to #FB2C36 (red) on screen.
// Without these, the default destructive/success/warning tokens would
// appear as their unintuitive complements.
colorOverrides: {
destructive: "#04d3c9",
destructiveForeground: "#000000",
success: "#b5217f",
warning: "#0042c7",
},
// Pre-inverted data-series accents for the Analytics/Models token
// charts. The defaults (#ffe6cb cream + #34d399 emerald) would render
// through the FG difference layer as dark navy + hot-coral on the
// bright Nous-blue canvas — the coral is the "red" users see for
// Output values without these overrides. Source → on-screen:
// Input: #ffe6cb → #001934 (dark navy) ← unchanged
// Output: #ffac02 → #0053fd (vivid Nous-blue) ← brand accent
// Input keeps the cream source so it stays a neutral, low-contrast
// dark-blue against the cream canvas; output paints as the brand
// Nous-blue so the "primary" series in token-flow charts reads as
// the highlight color, matching the rest of the inverted UI chrome.
seriesColors: {
inputTokenAccent: "#ffe6cb",
outputTokenAccent: "#ffac02",
},
// Explicit picker swatch — the raw palette hex (`#170d02`, `#FFAC02`,
// amber rgba) doesn't reflect what users see after the FG inversion,
// so we paint the post-inversion visual triplet directly:
// white → vivid Nous-blue → cream/light-blue
// matching the actual on-screen rendering of the theme.
swatchColors: ["#FFFFFF", "#0053FD", "#E8F2FD"],
};
/**
* Same look as ``defaultTheme`` but with a larger root font size, looser
* line-height, and ``spacious`` density so every rem-based size in the
@@ -208,6 +302,7 @@ export const defaultLargeTheme: DashboardTheme = {
export const BUILTIN_THEMES: Record<string, DashboardTheme> = {
default: defaultTheme,
"default-large": defaultLargeTheme,
"nous-blue": nousBlueTheme,
midnight: midnightTheme,
ember: emberTheme,
mono: monoTheme,
+28
View File
@@ -119,6 +119,25 @@ export interface ThemeComponentStyles {
page?: Record<string, string>;
}
/** Data-series accent colors for chart + table visualisations (Analytics,
* Models, etc.). Themes provide hex strings; the provider emits them as
* `--series-input-token` / `--series-output-token` CSS vars consumed
* inline by pages that render input-vs-output token flows. Themes can
* omit either field to inherit the default token defined in
* `index.css` (Hermes-teal `#ffe6cb` for input, `#34d399` for output).
*
* Inverted-lens themes (e.g. Nous Blue) must pre-invert these hex
* values so they read as their intended visual color after the FG
* difference layer flips them (`out = 255 channel`). E.g. to make
* output paint as Nous-blue `#0053FD` on screen, set
* `outputTokenAccent: "#FFAC02"` the difference math reverses it. */
export interface ThemeSeriesColors {
/** Input-tokens series accent (Analytics chart bars + table values). */
inputTokenAccent?: string;
/** Output-tokens series accent. */
outputTokenAccent?: string;
}
/** Optional hex overrides keyed by shadcn-compat token name (without the
* `--color-` prefix). Any key set here wins over the DS cascade. */
export interface ThemeColorOverrides {
@@ -162,6 +181,15 @@ export interface DashboardTheme {
/** Per-component CSS-var overrides. See `ThemeComponentStyles`. */
componentStyles?: ThemeComponentStyles;
colorOverrides?: ThemeColorOverrides;
/** Data-series accent colors for Analytics/Models token charts.
* See `ThemeSeriesColors` for inversion-aware values. */
seriesColors?: ThemeSeriesColors;
/** Explicit 3-color swatch override for the theme picker. Use when the
* palette's raw hex values don't reflect what users see on screen
* e.g. inverted "lens" themes whose foreground-difference layer flips
* the authored colors to their visual complements. Order matches the
* default swatch cells: [background, midground, warmGlow]. */
swatchColors?: [string, string, string];
/** Background color for the embedded terminal pane (xterm.js).
* Hex string. Defaults to `"#000000"` when absent. */
terminalBackground?: string;