mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-06-18 09:51:59 +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>
228 lines
6.6 KiB
TypeScript
228 lines
6.6 KiB
TypeScript
import { ListItem } from "@nous-research/ui/ui/components/list-item";
|
|
import {
|
|
AlertCircle,
|
|
Check,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Zap,
|
|
} from "lucide-react";
|
|
import { useEffect, useState } from "react";
|
|
|
|
/**
|
|
* Expandable tool call row — the web equivalent of Ink's ToolTrail node.
|
|
*
|
|
* Renders one `tool.start` + `tool.complete` pair (plus any `tool.progress`
|
|
* in between) as a single collapsible item in the transcript:
|
|
*
|
|
* ▸ ● read_file(path=/foo) 2.3s
|
|
*
|
|
* Click the header to reveal a preformatted body with context (args), the
|
|
* streaming preview (while running), and the final summary or error. Error
|
|
* rows auto-expand so failures aren't silently collapsed.
|
|
*/
|
|
|
|
export interface ToolEntry {
|
|
kind: "tool";
|
|
id: string;
|
|
tool_id: string;
|
|
name: string;
|
|
context?: string;
|
|
preview?: string;
|
|
summary?: string;
|
|
error?: string;
|
|
inline_diff?: string;
|
|
status: "running" | "done" | "error";
|
|
startedAt: number;
|
|
completedAt?: number;
|
|
}
|
|
|
|
const STATUS_TONE: Record<ToolEntry["status"], string> = {
|
|
running: "border-primary/40 bg-primary/[0.04]",
|
|
done: "border-border bg-muted/20",
|
|
error: "border-destructive/50 bg-destructive/[0.04]",
|
|
};
|
|
|
|
const BULLET_TONE: Record<ToolEntry["status"], string> = {
|
|
running: "text-primary",
|
|
done: "text-primary/80",
|
|
error: "text-destructive",
|
|
};
|
|
|
|
const TICK_MS = 500;
|
|
|
|
export function ToolCall({ tool }: { tool: ToolEntry }) {
|
|
// `open` is derived: errors default-expanded, everything else collapsed.
|
|
// `null` means "follow the default"; any explicit bool is the user's override.
|
|
// This lets a running tool flip to expanded automatically when it errors,
|
|
// without mirroring state in an effect.
|
|
const [userOverride, setUserOverride] = useState<boolean | null>(null);
|
|
const open = userOverride ?? tool.status === "error";
|
|
|
|
// Tick `now` while the tool is running so the elapsed label updates live.
|
|
const [now, setNow] = useState(() => Date.now());
|
|
useEffect(() => {
|
|
if (tool.status !== "running") return;
|
|
const id = window.setInterval(() => setNow(() => Date.now()), TICK_MS);
|
|
return () => window.clearInterval(id);
|
|
}, [tool.status]);
|
|
|
|
// Historical tools (hydrated from session.resume) signal missing timestamps
|
|
// with `startedAt === 0`; we hide the elapsed badge for those rather than
|
|
// rendering a misleading "0ms".
|
|
const hasTimestamps = tool.startedAt > 0;
|
|
const elapsed = hasTimestamps
|
|
? fmtElapsed((tool.completedAt ?? now) - tool.startedAt)
|
|
: null;
|
|
|
|
const hasBody = !!(
|
|
tool.context ||
|
|
tool.preview ||
|
|
tool.summary ||
|
|
tool.error ||
|
|
tool.inline_diff
|
|
);
|
|
|
|
const Chevron = open ? ChevronDown : ChevronRight;
|
|
|
|
return (
|
|
<div
|
|
className={`rounded-md border overflow-hidden ${STATUS_TONE[tool.status]}`}
|
|
>
|
|
<ListItem
|
|
onClick={() => setUserOverride(!open)}
|
|
disabled={!hasBody}
|
|
aria-expanded={open}
|
|
className="px-2.5 py-1.5 text-xs hover:bg-foreground/2 disabled:cursor-default"
|
|
>
|
|
{hasBody ? (
|
|
<Chevron className="h-3 w-3 shrink-0 text-muted-foreground" />
|
|
) : (
|
|
<span className="w-3 shrink-0" />
|
|
)}
|
|
|
|
<Zap className={`h-3 w-3 shrink-0 ${BULLET_TONE[tool.status]}`} />
|
|
|
|
<span className="font-mono font-medium shrink-0">{tool.name}</span>
|
|
|
|
<span className="font-mono text-text-secondary truncate min-w-0 flex-1">
|
|
{tool.context ?? ""}
|
|
</span>
|
|
|
|
{tool.status === "running" && (
|
|
<span
|
|
className="inline-block h-2 w-2 rounded-full bg-primary animate-pulse shrink-0"
|
|
title="running"
|
|
/>
|
|
)}
|
|
{tool.status === "error" && (
|
|
<AlertCircle
|
|
className="h-3 w-3 shrink-0 text-destructive"
|
|
aria-label="error"
|
|
/>
|
|
)}
|
|
{tool.status === "done" && (
|
|
<Check
|
|
className="h-3 w-3 shrink-0 text-primary/80"
|
|
aria-label="done"
|
|
/>
|
|
)}
|
|
|
|
{elapsed && (
|
|
<span className="font-mono text-xs text-text-tertiary tabular-nums shrink-0">
|
|
{elapsed}
|
|
</span>
|
|
)}
|
|
</ListItem>
|
|
|
|
{open && hasBody && (
|
|
<div className="border-t border-border/60 px-3 py-2 space-y-2 text-xs font-mono">
|
|
{tool.context && <Section label="context">{tool.context}</Section>}
|
|
|
|
{tool.preview && tool.status === "running" && (
|
|
<Section label="streaming">
|
|
{tool.preview}
|
|
<span className="inline-block w-1.5 h-3 align-middle bg-foreground/40 ml-0.5 animate-pulse" />
|
|
</Section>
|
|
)}
|
|
|
|
{tool.inline_diff && (
|
|
<Section label="diff">
|
|
<pre className="whitespace-pre overflow-x-auto text-[0.7rem] leading-snug">
|
|
{colorizeDiff(tool.inline_diff)}
|
|
</pre>
|
|
</Section>
|
|
)}
|
|
|
|
{tool.summary && (
|
|
<Section label="result">
|
|
<span className="text-foreground/90 whitespace-pre-wrap">
|
|
{tool.summary}
|
|
</span>
|
|
</Section>
|
|
)}
|
|
|
|
{tool.error && (
|
|
<Section label="error" tone="error">
|
|
<span className="text-destructive whitespace-pre-wrap">
|
|
{tool.error}
|
|
</span>
|
|
</Section>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Section({
|
|
label,
|
|
children,
|
|
tone,
|
|
}: {
|
|
label: string;
|
|
children: React.ReactNode;
|
|
tone?: "error";
|
|
}) {
|
|
return (
|
|
<div className="flex gap-3">
|
|
<span
|
|
className={`text-display font-mondwest tracking-wider text-xs shrink-0 w-20 pt-0.5 ${
|
|
tone === "error" ? "text-destructive" : "text-text-tertiary"
|
|
}`}
|
|
>
|
|
{label}
|
|
</span>
|
|
|
|
<div className="flex-1 min-w-0 text-muted-foreground">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function fmtElapsed(ms: number): string {
|
|
const sec = Math.max(0, ms) / 1000;
|
|
if (sec < 1) return `${Math.round(ms)}ms`;
|
|
if (sec < 10) return `${sec.toFixed(1)}s`;
|
|
if (sec < 60) return `${Math.round(sec)}s`;
|
|
|
|
const m = Math.floor(sec / 60);
|
|
const s = Math.round(sec % 60);
|
|
return s ? `${m}m ${s}s` : `${m}m`;
|
|
}
|
|
|
|
/** Colorize unified-diff lines for the inline diff section. */
|
|
function colorizeDiff(diff: string): React.ReactNode {
|
|
return diff.split("\n").map((line, i) => (
|
|
<div key={i} className={diffLineClass(line)}>
|
|
{line || "\u00A0"}
|
|
</div>
|
|
));
|
|
}
|
|
|
|
function diffLineClass(line: string): string {
|
|
if (line.startsWith("+") && !line.startsWith("+++"))
|
|
return "text-success";
|
|
if (line.startsWith("-") && !line.startsWith("---"))
|
|
return "text-destructive";
|
|
if (line.startsWith("@@")) return "text-primary";
|
|
return "text-text-secondary";
|
|
}
|