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:
@@ -11,11 +11,27 @@ import fillerBgUrl from "@nous-research/ui/assets/filler-bg0.webp";
|
||||
* and the warm vignette both read theme-switchable CSS custom properties so
|
||||
* `ThemeProvider` can repaint the stack without remounting.
|
||||
*
|
||||
* z-1 bg = `var(--background-base)`, mix-blend-mode: difference
|
||||
* z-1 bg = `var(--background-base)`, mix-blend-mode driven by
|
||||
* `--component-backdrop-bg-blend-mode` (default `difference`).
|
||||
* Both LENS_0-style dark themes and the LENS_5I-style Nous Blue
|
||||
* light theme keep `difference` here — the canvas is flipped by
|
||||
* the z-200 FG inversion layer, not by changing this blend mode.
|
||||
* The CSS var is exposed as a hook so future presets can override
|
||||
* it (e.g. `multiply` to paint the bg as-is before inversion)
|
||||
* without touching this component.
|
||||
* z-2 bundled filler-bg WebP, inverted, opacity 0.033, difference
|
||||
* z-99 warm top-left vignette (`var(--warm-glow)`), opacity 0.22, lighten
|
||||
* z-101 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
|
||||
* color-dodge) — gated on GPU tier
|
||||
* z-200 FG inversion = `var(--foreground)` (opaque white in LENS_5I,
|
||||
* alpha-0 in LENS_0), mix-blend-mode: difference. This is the
|
||||
* layer that flips the dashboard into "light mode" for inverted
|
||||
* themes; for normal dark themes its alpha is 0 so it's a no-op.
|
||||
* Deliberately placed above every UI overlay z-index (modals,
|
||||
* tooltips, and dropUp dropdowns all sit at z-[100]) so portaled
|
||||
* elements get inverted along with the rest of the page instead
|
||||
* of painting with pre-inversion colors on top of the lens.
|
||||
* z-201 noise grain (SVG, ~55% opacity × `--noise-opacity-mul`,
|
||||
* color-dodge) — gated on GPU tier. Sits above the inversion
|
||||
* layer by design so the grain is not flipped.
|
||||
*
|
||||
* `useGpuTier` returns 0 when WebGL is unavailable, the renderer is a
|
||||
* software rasterizer (SwiftShader/llvmpipe), or the user has
|
||||
@@ -31,10 +47,13 @@ export function Backdrop() {
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-[1]"
|
||||
style={{
|
||||
backgroundColor: "var(--background-base)",
|
||||
mixBlendMode: "difference",
|
||||
}}
|
||||
style={
|
||||
{
|
||||
backgroundColor: "var(--background-base)",
|
||||
mixBlendMode:
|
||||
"var(--component-backdrop-bg-blend-mode, difference)",
|
||||
} as unknown as React.CSSProperties
|
||||
}
|
||||
/>
|
||||
|
||||
<div
|
||||
@@ -75,10 +94,35 @@ export function Backdrop() {
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Foreground inversion layer. Source-of-truth: LENS_5I.Lens.fgOpacity
|
||||
+ fgBlend: 'difference' in `design-language/src/ui/components/
|
||||
overlays/lens.ts`. With `--foreground-alpha: 0` (LENS_0 dark default)
|
||||
the layer is fully transparent and contributes nothing; with
|
||||
alpha 1 + opaque white it inverts the entire stack below it,
|
||||
producing the LENS_5I "light mode" look without altering any
|
||||
downstream component code.
|
||||
|
||||
z-200 (not 100) so it sits above every portaled UI overlay —
|
||||
sidebar tooltips, dropUp dropdowns, and modal dialogs all use
|
||||
z-[100], which is what the DS Lens picks too; portals append
|
||||
at the end of <body>, so equal z-index + later DOM order means
|
||||
they'd paint on top of the inversion and skip the flip. Inlined
|
||||
z-index for the same reason the DS does it — Tailwind's JIT
|
||||
scan sometimes drops non-default z utilities. */}
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0"
|
||||
style={{
|
||||
backgroundColor: "var(--foreground)",
|
||||
mixBlendMode: "difference",
|
||||
zIndex: 200,
|
||||
}}
|
||||
/>
|
||||
|
||||
{gpuTier > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="pointer-events-none fixed inset-0 z-[101]"
|
||||
className="pointer-events-none fixed inset-0 z-[201]"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"url(\"data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' fill='%23eaeaea' filter='url(%23n)' opacity='0.6'/%3E%3C/svg%3E\")",
|
||||
|
||||
@@ -86,7 +86,7 @@ export function ModelInfoCard({
|
||||
{hasCaps && (
|
||||
<div className="flex flex-wrap items-center gap-1.5 pt-0.5">
|
||||
{caps.supports_tools && (
|
||||
<span className="inline-flex items-center gap-1 bg-emerald-500/10 px-2 py-0.5 text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
||||
<span className="inline-flex items-center gap-1 bg-success/10 px-2 py-0.5 text-xs font-medium text-success">
|
||||
<Wrench className="h-2.5 w-2.5" /> Tools
|
||||
</span>
|
||||
)}
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useCallback } from "react";
|
||||
import { Input } from "@nous-research/ui/ui/components/input";
|
||||
import { Label } from "@nous-research/ui/ui/components/label";
|
||||
import { Select, SelectOption } from "@nous-research/ui/ui/components/select";
|
||||
import { Button } from "@nous-research/ui/ui/components/button";
|
||||
import { useI18n } from "@/i18n";
|
||||
import {
|
||||
buildScheduleString,
|
||||
DEFAULT_SCHEDULE_STATE,
|
||||
type IntervalUnit,
|
||||
type ScheduleBuilderState,
|
||||
type ScheduleMode,
|
||||
type Weekday,
|
||||
WEEKDAY_INDEXES,
|
||||
} from "@/lib/schedule";
|
||||
|
||||
/**
|
||||
* Human-readable schedule picker for cron job create/edit flows.
|
||||
*
|
||||
* Replaces the raw "type a cron expression" input that lived inline in
|
||||
* ``CronPage``. The picker still emits a single backend-compatible
|
||||
* schedule string (see ``cron/jobs.py::parse_schedule``), but the user
|
||||
* fills out shape-appropriate inputs (time picker, weekday toggles,
|
||||
* datetime-local field) per mode.
|
||||
*
|
||||
* Architecture:
|
||||
*
|
||||
* - The component is fully controlled. Parent owns the
|
||||
* ``ScheduleBuilderState`` and the derived schedule string (built
|
||||
* via ``buildScheduleString`` in render).
|
||||
* - Mode-specific state slots (``timeOfDay``, ``weekdays``, ...) are
|
||||
* preserved across mode switches so flipping back to a previous mode
|
||||
* doesn't erase the user's work.
|
||||
* - The "Custom" mode is an escape hatch — surfacing it as a normal
|
||||
* option (instead of hiding it behind an "advanced" toggle) keeps
|
||||
* power-user workflows discoverable without making everyone scroll
|
||||
* past it.
|
||||
*/
|
||||
export function ScheduleBuilder({ onChange, value }: ScheduleBuilderProps) {
|
||||
const { t } = useI18n();
|
||||
const cronStrings = t.cron;
|
||||
const modeStrings = cronStrings.scheduleModes;
|
||||
|
||||
const update = useCallback(
|
||||
(patch: Partial<ScheduleBuilderState>) => {
|
||||
onChange({ ...value, ...patch });
|
||||
},
|
||||
[onChange, value],
|
||||
);
|
||||
|
||||
const toggleWeekday = useCallback(
|
||||
(day: Weekday) => {
|
||||
const present = value.weekdays.includes(day);
|
||||
update({
|
||||
weekdays: present
|
||||
? value.weekdays.filter((d) => d !== day)
|
||||
: [...value.weekdays, day],
|
||||
});
|
||||
},
|
||||
[update, value.weekdays],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="grid gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-schedule-mode">
|
||||
{cronStrings.scheduleMode ?? "Schedule"}
|
||||
</Label>
|
||||
<Select
|
||||
id="cron-schedule-mode"
|
||||
value={value.mode}
|
||||
onValueChange={(v) => update({ mode: v as ScheduleMode })}
|
||||
>
|
||||
<SelectOption value="interval">{modeStrings.interval}</SelectOption>
|
||||
<SelectOption value="daily">{modeStrings.daily}</SelectOption>
|
||||
<SelectOption value="weekly">{modeStrings.weekly}</SelectOption>
|
||||
<SelectOption value="monthly">{modeStrings.monthly}</SelectOption>
|
||||
<SelectOption value="once">{modeStrings.once}</SelectOption>
|
||||
<SelectOption value="custom">{modeStrings.custom}</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{value.mode === "interval" && (
|
||||
<div className="grid grid-cols-[1fr_1.4fr] gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-interval-value">
|
||||
{modeStrings.intervalEvery}
|
||||
</Label>
|
||||
<Input
|
||||
id="cron-interval-value"
|
||||
type="number"
|
||||
min={1}
|
||||
max={9999}
|
||||
value={String(value.intervalValue)}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
update({
|
||||
intervalValue: Number.isFinite(n) && n > 0 ? n : 1,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-interval-unit">{modeStrings.intervalUnit}</Label>
|
||||
<Select
|
||||
id="cron-interval-unit"
|
||||
value={value.intervalUnit}
|
||||
onValueChange={(v) => update({ intervalUnit: v as IntervalUnit })}
|
||||
>
|
||||
<SelectOption value="minutes">
|
||||
{modeStrings.unitMinutes}
|
||||
</SelectOption>
|
||||
<SelectOption value="hours">{modeStrings.unitHours}</SelectOption>
|
||||
<SelectOption value="days">{modeStrings.unitDays}</SelectOption>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value.mode === "daily" && (
|
||||
<TimeOfDayField
|
||||
id="cron-daily-time"
|
||||
label={modeStrings.timeOfDay}
|
||||
value={value.timeOfDay}
|
||||
onChange={(timeOfDay) => update({ timeOfDay })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{value.mode === "weekly" && (
|
||||
<>
|
||||
<div className="grid gap-2">
|
||||
<Label>{modeStrings.weekdays}</Label>
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
role="group"
|
||||
aria-label={modeStrings.weekdays}
|
||||
>
|
||||
{WEEKDAY_INDEXES.map((d) => {
|
||||
const isOn = value.weekdays.includes(d);
|
||||
return (
|
||||
<Button
|
||||
key={d}
|
||||
type="button"
|
||||
size="sm"
|
||||
outlined={!isOn}
|
||||
aria-pressed={isOn}
|
||||
onClick={() => toggleWeekday(d)}
|
||||
className="min-w-[2.5rem] font-mono-ui text-xs uppercase"
|
||||
>
|
||||
{modeStrings.weekdaysShort[d]}
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<TimeOfDayField
|
||||
id="cron-weekly-time"
|
||||
label={modeStrings.timeOfDay}
|
||||
value={value.timeOfDay}
|
||||
onChange={(timeOfDay) => update({ timeOfDay })}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{value.mode === "monthly" && (
|
||||
<div className="grid grid-cols-[1fr_1fr] gap-3">
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-month-day">{modeStrings.dayOfMonth}</Label>
|
||||
<Input
|
||||
id="cron-month-day"
|
||||
type="number"
|
||||
min={1}
|
||||
max={31}
|
||||
value={String(value.dayOfMonth)}
|
||||
onChange={(e) => {
|
||||
const n = parseInt(e.target.value, 10);
|
||||
update({
|
||||
dayOfMonth:
|
||||
Number.isFinite(n) && n >= 1 && n <= 31 ? n : 1,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<TimeOfDayField
|
||||
id="cron-monthly-time"
|
||||
label={modeStrings.timeOfDay}
|
||||
value={value.timeOfDay}
|
||||
onChange={(timeOfDay) => update({ timeOfDay })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value.mode === "once" && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-once-at">{modeStrings.onceAt}</Label>
|
||||
{/* Native datetime-local — emits the exact "YYYY-MM-DDTHH:MM"
|
||||
shape ``parse_schedule`` accepts on the backend. */}
|
||||
<input
|
||||
id="cron-once-at"
|
||||
type="datetime-local"
|
||||
className="flex h-9 w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
value={value.onceAt}
|
||||
onChange={(e) => update({ onceAt: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{value.mode === "custom" && (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor="cron-custom-expr">{modeStrings.customLabel}</Label>
|
||||
<Input
|
||||
id="cron-custom-expr"
|
||||
placeholder={modeStrings.customPlaceholder}
|
||||
value={value.custom}
|
||||
onChange={(e) => update({ custom: e.target.value })}
|
||||
className="font-mono-ui"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{modeStrings.customHint}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline preview of what we'll send to the backend. Helps users
|
||||
eyeball the result before hitting Create, and keeps the
|
||||
schedule grammar discoverable for the custom mode. */}
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<span className="opacity-70">{modeStrings.preview}: </span>
|
||||
<span className="font-mono-ui text-foreground">
|
||||
{buildScheduleString(value) || modeStrings.previewEmpty}
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimeOfDayField({
|
||||
id,
|
||||
label,
|
||||
onChange,
|
||||
value,
|
||||
}: TimeOfDayFieldProps) {
|
||||
return (
|
||||
<div className="grid gap-2">
|
||||
<Label htmlFor={id}>{label}</Label>
|
||||
{/* Native time picker is the right tool for "HH:MM" — saves us
|
||||
two separate hour/minute selects, respects user locale's
|
||||
AM/PM preference, and round-trips with ``buildScheduleString``
|
||||
without parsing. */}
|
||||
<input
|
||||
id={id}
|
||||
type="time"
|
||||
className="flex h-9 w-full border border-border bg-background/40 px-3 py-2 text-sm font-courier shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-foreground/30 focus-visible:border-foreground/25"
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export { DEFAULT_SCHEDULE_STATE };
|
||||
|
||||
interface ScheduleBuilderProps {
|
||||
onChange: (state: ScheduleBuilderState) => void;
|
||||
value: ScheduleBuilderState;
|
||||
}
|
||||
|
||||
interface TimeOfDayFieldProps {
|
||||
id: string;
|
||||
label: string;
|
||||
onChange: (value: string) => void;
|
||||
value: string;
|
||||
}
|
||||
@@ -208,15 +208,25 @@ function ThemeSwitcherOptions({
|
||||
}
|
||||
|
||||
function ThemeSwatch({ theme }: { theme: DashboardTheme }) {
|
||||
const { background, midground, warmGlow } = theme.palette;
|
||||
// Inverted themes (Nous Blue / future lens themes) author their palette
|
||||
// pre-inversion — `#FFAC02` reads as `#0053FD` blue once the foreground-
|
||||
// difference layer flips the page. The picker can't replay that math
|
||||
// cheaply, so themes opt-in to an explicit `swatchColors` triplet that
|
||||
// mirrors the on-screen result. Falls back to the raw palette hexes for
|
||||
// every other theme so existing dark-theme swatches are untouched.
|
||||
const [c1, c2, c3] = theme.swatchColors ?? [
|
||||
theme.palette.background.hex,
|
||||
theme.palette.midground.hex,
|
||||
theme.palette.warmGlow,
|
||||
];
|
||||
return (
|
||||
<div
|
||||
aria-hidden
|
||||
className="flex h-4 w-9 shrink-0 overflow-hidden border border-current/20"
|
||||
>
|
||||
<span className="flex-1" style={{ background: background.hex }} />
|
||||
<span className="flex-1" style={{ background: midground.hex }} />
|
||||
<span className="flex-1" style={{ background: warmGlow }} />
|
||||
<span className="flex-1" style={{ background: c1 }} />
|
||||
<span className="flex-1" style={{ background: c2 }} />
|
||||
<span className="flex-1" style={{ background: c3 }} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -220,7 +220,7 @@ function colorizeDiff(diff: string): React.ReactNode {
|
||||
|
||||
function diffLineClass(line: string): string {
|
||||
if (line.startsWith("+") && !line.startsWith("+++"))
|
||||
return "text-emerald-500 dark:text-emerald-400";
|
||||
return "text-success";
|
||||
if (line.startsWith("-") && !line.startsWith("---"))
|
||||
return "text-destructive";
|
||||
if (line.startsWith("@@")) return "text-primary";
|
||||
|
||||
Reference in New Issue
Block a user