mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-12 08:51:53 +00:00
* 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>
273 lines
9.3 KiB
TypeScript
273 lines
9.3 KiB
TypeScript
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;
|
|
}
|