feat: analytics parity — tool cost, date ranges, budgets, query explorer, replay, snapshots (v2.4.0)

Analytics parity phases 2–7 (docs/plans/analytics-parity/), built on
138e706 and ported onto v2.3.1:
- Cost by AI tool (?tool= on cost/analytics/stats/workflows, by_tool,
  Codex fast/priority and flex tiers, agenthub_cost_usd_total{tool,model})
- Date ranges (from/to) on cost/analytics/workflows; Analytics range select
  with Today / This week (Monday) / This month (1st) / 7d / 30d / 90d / All /
  Custom; workflow filters carried into workflow-stats.js (read worker)
- Budgets and cost alerts as alert rule types (alert_rules rebuilt without
  its rule_type CHECK), GET /api/budgets/status, rule preview, spend pace,
  status-bar budget chip; rules stay editable in place
- Query Explorer (/query, allowlisted sort, CSV/JSON export) and scheduled
  reports (report_definitions/report_runs, Settings → Reports)
- Session Replay tab and immutable snapshots (session_snapshots, /snapshot/:token)
- Remote Codex sync, push ingest with a hashed token, privacy redaction and
  ignored folders (Settings → Data), Cursor sessions (no cost)
- Read-only MCP server, statusline, Grafana/Prometheus files

Also: resizable sidebar, pet run/wave animations, the workspace home keeps a
half-typed prompt, and the pre-commit hook runs only the related tests for a
small commit.
This commit is contained in:
2026-10-06 17:12:28 +07:00
parent 837da6e5de
commit 4aae61297f
146 changed files with 13565 additions and 537 deletions
+252 -12
View File
@@ -70,6 +70,8 @@ import {
BellOff,
Check,
CheckCheck,
ChevronDown,
Eye,
ListChecks,
Pencil,
Plus,
@@ -87,9 +89,10 @@ import { ConfirmModal } from "./ConfirmModal";
import { Checkbox } from "./Checkbox";
import { FieldHelp } from "./FieldHelp";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "./ContextMenu";
import { timeAgo } from "../lib/format";
import { fmtCost, timeAgo } from "../lib/format";
import { toast } from "../lib/toast";
import type { AlertEvent, AlertRule, AlertRuleType, WSMessage } from "../lib/types";
import { useAiTools } from "../hooks/useAiTools";
import type { AlertEvent, AlertRule, AlertRuleType, RulePreview, WSMessage } from "../lib/types";
const PAGE_SIZE = 25;
@@ -124,6 +127,8 @@ const RULE_TYPES: AlertRuleType[] = [
"inactivity",
"status_duration",
"token_threshold",
"cost_threshold",
"budget",
];
type TabKey = "rules" | "channels" | "activity";
@@ -139,6 +144,11 @@ interface RuleFormState {
minutes: string;
status: "working" | "waiting";
total_tokens: string;
usd: string;
period: "day" | "week" | "month";
limit_usd: string;
thresholds: string;
budget_tool: string;
cooldown_seconds: string;
}
@@ -153,6 +163,11 @@ const EMPTY_FORM: RuleFormState = {
minutes: "10",
status: "working",
total_tokens: "1000000",
usd: "5",
period: "month",
limit_usd: "100",
thresholds: "80, 100",
budget_tool: "",
cooldown_seconds: "300",
};
@@ -171,12 +186,28 @@ function formFromRule(rule: AlertRule): RuleFormState {
minutes: str(c.minutes, EMPTY_FORM.minutes),
status: c.status === "waiting" ? "waiting" : "working",
total_tokens: str(c.total_tokens, EMPTY_FORM.total_tokens),
usd: str(c.usd, EMPTY_FORM.usd),
period: c.period === "day" || c.period === "week" ? c.period : "month",
limit_usd: str(c.limit_usd, EMPTY_FORM.limit_usd),
thresholds: Array.isArray(c.thresholds) ? c.thresholds.join(", ") : EMPTY_FORM.thresholds,
budget_tool: str(c.tool, ""),
cooldown_seconds: String(rule.cooldown_seconds),
};
}
const errText = (err: unknown) => (err instanceof Error ? err.message : String(err));
/** "80, 100" → [80, 100]; null when any part is not a whole percent 1..1000. */
function parseThresholds(text: string): number[] | null {
const parts = text
.split(",")
.map((p) => p.trim())
.filter(Boolean);
const nums = parts.map((p) => Number(p));
if (nums.length === 0 || nums.length > 10) return null;
return nums.every((n) => Number.isInteger(n) && n > 0 && n <= 1000) ? nums : null;
}
function buildConfig(form: RuleFormState): AlertRule["config"] {
switch (form.rule_type) {
case "event_pattern": {
@@ -198,6 +229,18 @@ function buildConfig(form: RuleFormState): AlertRule["config"] {
return { status: form.status, minutes: parseFloat(form.minutes) };
case "token_threshold":
return { total_tokens: parseInt(form.total_tokens, 10) };
case "cost_threshold":
return { usd: parseFloat(form.usd) };
case "budget": {
const config: AlertRule["config"] = {
period: form.period,
limit_usd: parseFloat(form.limit_usd),
thresholds: parseThresholds(form.thresholds) ?? [80, 100],
tz_offset: new Date().getTimezoneOffset(),
};
if (form.budget_tool) config.tool = form.budget_tool;
return config;
}
}
}
@@ -225,6 +268,14 @@ function describeRule(rule: AlertRule, t: (key: string, opts?: Record<string, un
return t("ruleDesc.statusDuration", { status: c.status, minutes: c.minutes });
case "token_threshold":
return t("ruleDesc.tokenThreshold", { tokens: (c.total_tokens ?? 0).toLocaleString() });
case "cost_threshold":
return t("ruleDesc.costThreshold", { usd: fmtCost(c.usd ?? 0) });
case "budget":
return t("ruleDesc.budget", {
limit: fmtCost(c.limit_usd ?? 0),
period: t(`periods.${c.period ?? "month"}`),
thresholds: (c.thresholds ?? []).join("%, ") + "%",
});
}
}
@@ -244,6 +295,9 @@ export function AlertsNotifications() {
const [formError, setFormError] = useState<string | null>(null);
const [saving, setSaving] = useState(false);
const [confirmRule, setConfirmRule] = useState<AlertRule | null>(null);
const [preview, setPreview] = useState<RulePreview | null>(null);
const [previewing, setPreviewing] = useState(false);
const aiTools = useAiTools();
const ruleCtx = useContextMenu<AlertRule>();
// A rule-menu item: close the menu, then act on the rule it was opened on.
const pickRule = (fn: (rule: AlertRule) => unknown) => () => {
@@ -322,10 +376,35 @@ export function AlertsNotifications() {
});
}, [loadAlerts]);
const set = (patch: Partial<RuleFormState>) => setForm((prev) => ({ ...prev, ...patch }));
// Any edit makes a shown preview stale.
const set = (patch: Partial<RuleFormState>) => {
setPreview(null);
setForm((prev) => ({ ...prev, ...patch }));
};
const onPreview = async () => {
if (previewing) return;
setPreviewing(true);
setFormError(null);
try {
const cooldown = parseInt(form.cooldown_seconds, 10);
setPreview(
await api.alerts.rules.preview({
rule_type: form.rule_type,
config: buildConfig(form),
cooldown_seconds: Number.isFinite(cooldown) && cooldown >= 0 ? cooldown : 300,
})
);
} catch (err) {
setFormError(err instanceof Error ? err.message : String(err));
} finally {
setPreviewing(false);
}
};
const closeForm = () => {
setForm(EMPTY_FORM);
setPreview(null);
setEditingId(null);
setFormOpen(false);
setFormError(null);
@@ -333,6 +412,7 @@ export function AlertsNotifications() {
const openEditRule = (rule: AlertRule) => {
setForm(formFromRule(rule));
setPreview(null);
setEditingId(rule.id);
setFormError(null);
setFormOpen(true);
@@ -417,7 +497,10 @@ export function AlertsNotifications() {
(countVal <= 1 || (Number.isFinite(windowVal) && windowVal > 0)))) &&
((form.rule_type !== "inactivity" && form.rule_type !== "status_duration") ||
(Number.isFinite(minutesVal) && minutesVal > 0)) &&
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0));
(form.rule_type !== "token_threshold" || (Number.isFinite(tokensVal) && tokensVal > 0)) &&
(form.rule_type !== "cost_threshold" || parseFloat(form.usd) > 0) &&
(form.rule_type !== "budget" ||
(parseFloat(form.limit_usd) > 0 && parseThresholds(form.thresholds) !== null));
const TABS: { key: TabKey; label: string; icon: typeof ListChecks; badge?: number }[] = [
{
@@ -680,6 +763,100 @@ export function AlertsNotifications() {
</div>
)}
{form.rule_type === "cost_threshold" && (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.usd")}
<FieldHelp description={t("rules.help.usd")} />
</span>
<input
type="number"
min={0}
step="0.01"
value={form.usd}
onChange={(e) => set({ usd: e.target.value })}
className="input mt-1 w-full"
/>
</label>
</div>
)}
{form.rule_type === "budget" && (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-3">
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.period")}
<FieldHelp description={t("rules.help.period")} />
</span>
<div className="relative mt-1">
<select
value={form.period}
onChange={(e) => set({ period: e.target.value as RuleFormState["period"] })}
className="input w-full appearance-none pr-8"
>
{(["day", "week", "month"] as const).map((p) => (
<option key={p} value={p}>
{t(`periods.${p}`)}
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.limitUsd")}
<FieldHelp description={t("rules.help.limitUsd")} />
</span>
<input
type="number"
min={0}
step="0.01"
value={form.limit_usd}
onChange={(e) => set({ limit_usd: e.target.value })}
className="input mt-1 w-full"
/>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.thresholds")}
<FieldHelp description={t("rules.help.thresholds")} />
</span>
<input
type="text"
value={form.thresholds}
onChange={(e) => set({ thresholds: e.target.value })}
placeholder="80, 100"
className="input mt-1 w-full"
/>
</label>
<label className="block text-xs text-fg-secondary">
<span className="inline-flex items-center gap-1">
{t("rules.form.budgetTool")}
<FieldHelp description={t("rules.help.budgetTool")} />
</span>
<div className="relative mt-1">
<select
value={form.budget_tool}
onChange={(e) => set({ budget_tool: e.target.value })}
className="input w-full appearance-none pr-8"
>
<option value="">{t("rules.form.allTools")}</option>
{aiTools.tools.map((x) => (
<option key={x.id} value={x.id}>
{x.label}
</option>
))}
</select>
<ChevronDown className="w-3.5 h-3.5 absolute right-2.5 top-1/2 -translate-y-1/2 text-fg-muted pointer-events-none" />
</div>
</label>
</div>
)}
{preview && <RulePreviewResult preview={preview} />}
<div className="flex flex-wrap items-end justify-between gap-3">
<label className="block text-xs text-fg-secondary">
<span className="mb-1.5 flex items-center gap-1">
@@ -701,14 +878,29 @@ export function AlertsNotifications() {
</span>
)}
</label>
<button
onClick={onSaveRule}
disabled={!canSubmit || saving}
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingId ? <Check className="w-3.5 h-3.5" /> : <Plus className="w-3.5 h-3.5" />}
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
</button>
<div className="flex items-center gap-2">
<button
onClick={onPreview}
disabled={!canSubmit || previewing}
title={t("rules.previewHint")}
className="btn-ghost border border-border inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
<Eye className="w-3.5 h-3.5" />
{previewing ? t("rules.previewing") : t("rules.preview")}
</button>
<button
onClick={onSaveRule}
disabled={!canSubmit || saving}
className="btn-primary inline-flex items-center gap-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
>
{editingId ? (
<Check className="w-3.5 h-3.5" />
) : (
<Plus className="w-3.5 h-3.5" />
)}
{saving ? t("rules.saving") : editingId ? t("rules.save") : t("rules.create")}
</button>
</div>
</div>
{formError && <p className="text-xs text-status-danger">{formError}</p>}
</div>
@@ -927,3 +1119,51 @@ export function AlertsNotifications() {
</div>
);
}
/** What a rule would have fired, from POST /api/alerts/rules/preview. */
function RulePreviewResult({ preview }: { preview: RulePreview }) {
const { t } = useTranslation("alerts");
if (!preview.supported) {
return <p className="text-xs text-fg-muted">{t("rules.previewUnsupported")}</p>;
}
if (preview.periods) {
return (
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
<p className="text-fg-secondary">{t("rules.previewBudget")}</p>
{preview.periods.map((p) => (
<div key={p.period_start} className="flex justify-between gap-3 font-mono">
<span className="text-fg-muted">{p.period_start}</span>
<span className="text-fg-secondary">
{fmtCost(p.spend)} · {Math.round(p.pct)}%
</span>
<span className={p.crossed.length ? "text-status-warning" : "text-fg-muted"}>
{p.crossed.length ? p.crossed.map((c) => `${c}%`).join(" ") : "—"}
</span>
</div>
))}
</div>
);
}
return (
<div className="rounded-md border border-border bg-surface-1 p-2 text-xs space-y-1">
<p className="text-fg-secondary">
{t("rules.previewResult", {
fires: preview.fires,
sessions: preview.sessions,
days: preview.window_days,
})}
</p>
{preview.samples.map((s, i) => (
<div key={i} className="flex justify-between gap-3 font-mono text-fg-muted">
<Link
to={`/sessions/${encodeURIComponent(s.session_id)}`}
className="truncate hover:text-accent"
>
{s.session_id.slice(0, 8)}
</Link>
<span>{timeAgo(s.at)}</span>
</div>
))}
</div>
);
}
+2
View File
@@ -28,6 +28,8 @@ export function DocumentTitle() {
title = t("workspace");
} else if (path.startsWith("/settings")) {
title = t("settings");
} else if (path.startsWith("/query")) {
title = t("query");
} else if (path !== "/") {
title = t("notFound");
}
+289
View File
@@ -0,0 +1,289 @@
/**
* @file EventReplay.tsx
* @description The session Replay tab: a scrubber over a session's recorded
* events (play/pause, 0.5×–5×, step, jump, arrow keys), a lifecycle mini-map
* whose markers jump the cursor, the state derived up to the cursor
* (`lib/replay.ts`) and the selected event through EventDetail. Fed a list of
* events, so the live analysis page and a frozen snapshot share it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useMemo, useState, type KeyboardEvent, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Pause, Play, SkipBack, SkipForward, StepBack, StepForward } from "lucide-react";
import { api } from "../lib/api";
import {
chronological,
lifecycleMarkers,
replayState,
LIFECYCLE_TYPES,
REPLAY_SPEEDS,
REPLAY_STEP_MS,
} from "../lib/replay";
import { buildEventTitle } from "../lib/event-grouping";
import type { AgentInfo } from "../lib/event-grouping";
import { formatDateTimeFull, formatMs } from "../lib/format";
import type { DashboardEvent } from "../lib/types";
import { AgentStatusBadge } from "./StatusBadge";
import { EventDetail } from "./EventDetail";
/** The newest this many events are replayed; older ones are left out. */
export const REPLAY_MAX_EVENTS = 5000;
const PAGE = 500;
/** A session's events in playback order (the newest REPLAY_MAX_EVENTS). */
export async function loadReplayEvents(
sessionId: string
): Promise<{ events: DashboardEvent[]; total: number }> {
const all: DashboardEvent[] = [];
let total = 0;
do {
const r = await api.events.list({ session_id: sessionId, limit: PAGE, offset: all.length });
total = r.total;
if (r.events.length === 0) break;
all.push(...r.events);
} while (all.length < Math.min(total, REPLAY_MAX_EVENTS));
return { events: chronological(all.slice(0, REPLAY_MAX_EVENTS)), total };
}
const MARKER_COLOR: Record<string, string> = {
SessionStart: "bg-status-success",
UserPromptSubmit: "bg-accent",
Compaction: "bg-status-warning",
SubagentStop: "bg-violet-500",
APIError: "bg-status-danger",
Stop: "bg-fg-muted",
};
const btn =
"inline-flex h-8 w-8 items-center justify-center rounded-md text-fg-secondary hover:bg-surface-3 hover:text-fg-primary disabled:opacity-40 disabled:cursor-not-allowed";
export function EventReplay({
events: raw,
total,
agentInfoById,
sessionNameById,
}: {
events: DashboardEvent[];
/** The session's full event count, when more than `events` were recorded. */
total?: number;
agentInfoById: Map<string, AgentInfo>;
sessionNameById: Map<string, string>;
}) {
const { t } = useTranslation("sessions");
const events = useMemo(() => chronological(raw), [raw]);
const markers = useMemo(() => lifecycleMarkers(events), [events]);
const last = events.length - 1;
const [cursor, setCursor] = useState(0);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState<number>(1);
const at = Math.min(cursor, Math.max(0, last));
const state = useMemo(() => replayState(events, at), [events, at]);
const event = events[at];
useEffect(() => {
if (!playing) return;
if (at >= last) {
setPlaying(false);
return;
}
const timer = setTimeout(() => setCursor((c) => Math.min(c + 1, last)), REPLAY_STEP_MS / speed);
return () => clearTimeout(timer);
}, [playing, at, last, speed]);
if (!event) return <p className="text-sm text-fg-muted">{t("detail.noEvents")}</p>;
const go = (i: number) => setCursor(Math.max(0, Math.min(last, i)));
const toggle = () => {
if (!playing && at >= last) setCursor(0);
setPlaying((p) => !p);
};
const onKey = (e: KeyboardEvent) => {
// The slider steps itself on arrow keys; only the rest is ours there.
const onSlider = e.target instanceof HTMLInputElement;
if (e.key === " " && !(e.target instanceof HTMLButtonElement)) {
e.preventDefault();
toggle();
} else if (onSlider) return;
else if (e.key === "ArrowLeft") go(at - 1);
else if (e.key === "ArrowRight") go(at + 1);
else if (e.key === "Home") go(0);
else if (e.key === "End") go(last);
else return;
e.preventDefault();
};
const markerLabel = (type: string) => t(`replay.marker.${type}`);
const present = LIFECYCLE_TYPES.filter((k) => markers.some((m) => m.type === k));
return (
<div
className="space-y-4 outline-none"
tabIndex={0}
onKeyDown={onKey}
role="group"
aria-label={t("replay.label")}
data-testid="event-replay"
>
{total != null && total > events.length && (
<p className="text-xs text-fg-muted">
{t("replay.truncated", { shown: events.length, total })}
</p>
)}
<div className="card space-y-3 p-4">
<div className="flex flex-wrap items-center gap-1">
<button type="button" className={btn} onClick={() => go(0)} title={t("replay.start")}>
<SkipBack className="h-4 w-4" />
</button>
<button
type="button"
className={btn}
onClick={() => go(at - 1)}
disabled={at <= 0}
title={t("replay.back")}
>
<StepBack className="h-4 w-4" />
</button>
<button
type="button"
className={`${btn} bg-accent/15 text-accent`}
onClick={toggle}
title={playing ? t("replay.pause") : t("replay.play")}
aria-pressed={playing}
>
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
</button>
<button
type="button"
className={btn}
onClick={() => go(at + 1)}
disabled={at >= last}
title={t("replay.forward")}
>
<StepForward className="h-4 w-4" />
</button>
<button type="button" className={btn} onClick={() => go(last)} title={t("replay.end")}>
<SkipForward className="h-4 w-4" />
</button>
<div
className="ml-2 flex items-center gap-0.5"
role="group"
aria-label={t("replay.speed")}
>
{REPLAY_SPEEDS.map((s) => (
<button
key={s}
type="button"
onClick={() => setSpeed(s)}
aria-pressed={speed === s}
className={`rounded px-2 py-1 text-xs font-mono ${
speed === s
? "bg-accent/15 text-accent"
: "text-fg-muted hover:bg-surface-3 hover:text-fg-primary"
}`}
>
{s}×
</button>
))}
</div>
<span className="ml-auto font-mono text-xs text-fg-muted">
{t("replay.position", { at: at + 1, n: events.length })} ·{" "}
{formatDateTimeFull(event.created_at)}
</span>
</div>
<input
type="range"
min={0}
max={last}
value={at}
onChange={(e) => go(Number(e.target.value))}
aria-label={t("replay.scrubber")}
className="w-full accent-[rgb(var(--accent))]"
/>
<div
className="relative h-4 rounded bg-surface-2"
role="group"
aria-label={t("replay.minimap")}
>
<div
className="pointer-events-none absolute inset-y-0 w-px bg-fg-primary"
style={{ left: `${last > 0 ? (at / last) * 100 : 0}%` }}
/>
{markers.map((m) => (
<button
key={m.index}
type="button"
onClick={() => go(m.index)}
title={`${markerLabel(m.type)} · #${m.index + 1}`}
aria-label={`${markerLabel(m.type)} · #${m.index + 1}`}
className={`absolute inset-y-0 w-1.5 -translate-x-1/2 rounded-sm opacity-80 hover:opacity-100 ${MARKER_COLOR[m.type]}`}
style={{ left: `${m.pct}%` }}
/>
))}
</div>
{present.length > 0 && (
<div className="flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-fg-muted">
{present.map((k) => (
<span key={k} className="inline-flex items-center gap-1">
<span className={`h-2 w-2 rounded-sm ${MARKER_COLOR[k]}`} />
{markerLabel(k)}
</span>
))}
</div>
)}
</div>
<div className="grid grid-cols-2 gap-3 md:grid-cols-6">
<Tile label={t("replay.state.status")}>
{state.status ? <AgentStatusBadge status={state.status} /> : "-"}
</Tile>
<Tile label={t("replay.state.tool")}>
<span className="font-mono">{state.currentTool ?? "-"}</span>
</Tile>
<Tile label={t("replay.state.prompts")}>{state.prompts}</Tile>
<Tile label={t("replay.state.errors")}>{state.errors}</Tile>
<Tile label={t("replay.state.compactions")}>{state.compactions}</Tile>
<Tile label={t("replay.state.elapsed")}>{formatMs(state.elapsedMs)}</Tile>
</div>
{state.toolCounts.length > 0 && (
<div
className="flex flex-wrap gap-1.5"
role="group"
aria-label={t("replay.state.toolCounts")}
>
{state.toolCounts.map((x) => (
<span
key={x.tool}
className="rounded bg-surface-2 px-2 py-0.5 font-mono text-[11px] text-fg-secondary"
>
{x.tool} ×{x.count}
</span>
))}
</div>
)}
<div className="card overflow-hidden">
<div className="border-b border-border px-5 py-3 text-sm text-fg-secondary">
<span className="mr-2 font-mono text-xs text-fg-muted">#{at + 1}</span>
{buildEventTitle(event)}
</div>
<EventDetail
event={event}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
</div>
</div>
);
}
function Tile({ label, children }: { label: string; children: ReactNode }) {
return (
<div className="rounded-lg border border-border bg-surface-2 px-3 py-2">
<div className="text-[10px] uppercase tracking-wider text-fg-muted">{label}</div>
<div className="mt-1 text-sm font-semibold text-fg-primary">{children}</div>
</div>
);
}
+135
View File
@@ -0,0 +1,135 @@
/**
* @file EventTimeline.tsx
* @description A session's events as a flat, expandable list — one row per
* event (age, status badge, origin · title, tool), opening into EventDetail.
* Used by the session analysis page's Timeline tab and the read-only snapshot
* page.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { AgentStatusBadge } from "./StatusBadge";
import { EventDetail } from "./EventDetail";
import {
agentOriginLabel,
buildEventTitle,
buildOriginLabel,
projectFromCwd,
projectFromEvent,
statusFromEventType,
} from "../lib/event-grouping";
import type { AgentInfo } from "../lib/event-grouping";
import { timeAgo } from "../lib/format";
import type { DashboardEvent } from "../lib/types";
export function EventTimeline({
events,
agentInfoById,
sessionNameById,
fallbackCwd,
}: {
events: DashboardEvent[];
agentInfoById: Map<string, AgentInfo>;
sessionNameById: Map<string, string>;
/** The session's cwd, for events that carry none of their own. */
fallbackCwd?: string | null;
}) {
const { t } = useTranslation("sessions");
const [expandedEvents, setExpandedEvents] = useState<Set<number>>(() => new Set());
function toggleEvent(id: number) {
setExpandedEvents((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
}
// Precompute project per event so flat-row rendering doesn't re-parse JSON.
const projectByEventId = useMemo(() => {
const map = new Map<number, string | null>();
for (const e of events) map.set(e.id, projectFromEvent(e));
return map;
}, [events]);
return (
<div className="card overflow-hidden">
<div className="divide-y divide-border max-h-[600px] overflow-y-auto overflow-x-auto">
{events.map((event, i) => {
const key = event.id ?? i;
const isOpen = event.id != null && expandedEvents.has(event.id);
return (
// Off-screen rows skip layout/paint (a long timeline is not virtualized).
<div
key={key}
style={
isOpen
? undefined
: { contentVisibility: "auto", containIntrinsicSize: "auto 45px" }
}
>
<button
type="button"
onClick={() => event.id != null && toggleEvent(event.id)}
aria-expanded={isOpen}
aria-label={
isOpen ? t("common:eventDetail.collapse") : t("common:eventDetail.expand")
}
className="w-full text-left px-5 py-3 flex items-center gap-4 hover:bg-surface-4 transition-colors min-w-0 cursor-pointer"
>
<span
className={`text-fg-muted text-[10px] w-3 flex-shrink-0 transition-transform ${isOpen ? "rotate-90" : ""}`}
aria-hidden="true"
>
▶
</span>
<div className="w-16 text-[11px] text-fg-muted font-mono flex-shrink-0">
{timeAgo(event.created_at)}
</div>
<AgentStatusBadge status={statusFromEventType(event.event_type)} />
{(() => {
// Session is implicit on this page - project is
// still shown so the row identifies the working
// directory when you share / search. Events without
// their own cwd (e.g. TurnDuration) fall back to the
// session's cwd so every row carries the dir prefix.
const project =
projectByEventId.get(event.id) ?? projectFromCwd(fallbackCwd) ?? null;
const origin = buildOriginLabel(
project,
null,
agentOriginLabel(event.agent_id, agentInfoById)
);
return (
<span className="text-sm text-fg-secondary flex-1 truncate">
{origin && (
<span className="text-fg-muted mr-1" title={event.agent_id ?? undefined}>
{origin} ·
</span>
)}
{buildEventTitle(event)}
</span>
);
})()}
{event.tool_name && (
<span className="text-[11px] px-2 py-0.5 bg-surface-2 rounded text-fg-muted font-mono">
{event.tool_name}
</span>
)}
</button>
{isOpen && (
<EventDetail
event={event}
agentInfoById={agentInfoById}
sessionNameById={sessionNameById}
/>
)}
</div>
);
})}
</div>
</div>
);
}
+78 -2
View File
@@ -12,6 +12,11 @@
* written back on every toggle. Failures to access storage are swallowed so a
* private-browsing quota error never breaks the UI.
*
* ## Sidebar width
* The expanded sidebar's right edge drags (or takes ←/→) between
* {@link SIDEBAR_MIN} and {@link SIDEBAR_MAX} px — never past 40% of the
* window — kept in `agenthub.sidebar.width`; a double-click resets it.
*
* ## Narrow viewports
* With no stored preference the sidebar starts collapsed below `lg` (see
* {@link loadCollapsed}). Below `md` it leaves the flow entirely: a menu
@@ -79,6 +84,26 @@ import { UpdateNotifier } from "./UpdateNotifier";
import { Sagi } from "./Sagi/Sagi";
import { StatusBar } from "./StatusBar";
const WIDTH_KEY = "agenthub.sidebar.width";
/** The default width, and the narrowest the edge drags to. */
export const SIDEBAR_MIN = 240;
/** Wide enough for long workspace names on a large screen, no wider. */
export const SIDEBAR_MAX = 480;
/** A width inside the bounds, and at most 40% of the window. */
export function clampSidebar(px: number): number {
const max = Math.max(SIDEBAR_MIN, Math.min(SIDEBAR_MAX, window.innerWidth * 0.4));
return Math.round(Math.min(max, Math.max(SIDEBAR_MIN, px)));
}
function loadWidth(): number {
try {
const n = Number(localStorage.getItem(WIDTH_KEY));
if (n) return clampSidebar(n);
} catch {}
return SIDEBAR_MIN;
}
/** Below Tailwind's `md`: the sidebar becomes an off-canvas drawer. */
export const DRAWER_QUERY = "(max-width: 767.98px)";
@@ -112,7 +137,17 @@ export function Layout({ wsConnected }: LayoutProps) {
const [drawerOpen, setDrawerOpen] = useState(false);
const closeDrawer = useCallback(() => setDrawerOpen(false), []);
const { pathname, search } = useLocation();
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : "15rem";
const [width, setWidth] = useState(loadWidth);
const [resizing, setResizing] = useState(false);
const sidebarWidth = drawer ? "0rem" : collapsed ? "4.25rem" : `${width}px`;
const saveWidth = useCallback((px: number) => {
const next = clampSidebar(px);
setWidth(next);
try {
localStorage.setItem(WIDTH_KEY, String(next));
} catch {}
}, []);
// A pick in the drawer navigates; the drawer has done its job.
useEffect(closeDrawer, [pathname, search, closeDrawer]);
@@ -153,11 +188,52 @@ export function Layout({ wsConnected }: LayoutProps) {
connectionOpen={connectionOpen}
onConnectionOpenChange={setConnectionOpen}
onUpdateAvailableChange={setUpdateAvailable}
width={width}
resizing={resizing}
/>
{!drawer && !collapsed && (
<div
role="separator"
tabIndex={0}
aria-orientation="vertical"
aria-valuenow={width}
aria-valuemin={SIDEBAR_MIN}
aria-valuemax={SIDEBAR_MAX}
aria-label={t("resizeSidebar")}
title={t("resizeSidebar")}
data-testid="sidebar-resize"
className={`fixed top-0 bottom-0 z-30 w-1.5 -translate-x-1/2 cursor-col-resize outline-none transition-colors hover:bg-accent/40 focus-visible:bg-accent/60 ${
resizing ? "bg-accent/60" : ""
}`}
style={{ left: width }}
onPointerDown={(e) => {
e.preventDefault();
e.currentTarget.setPointerCapture?.(e.pointerId);
setResizing(true);
}}
onPointerMove={(e) => {
if (resizing) setWidth(clampSidebar(e.clientX));
}}
onPointerUp={(e) => {
if (!resizing) return;
setResizing(false);
saveWidth(e.clientX);
}}
onPointerCancel={() => setResizing(false)}
onDoubleClick={() => saveWidth(SIDEBAR_MIN)}
onKeyDown={(e) => {
if (e.key !== "ArrowLeft" && e.key !== "ArrowRight") return;
e.preventDefault();
saveWidth(width + (e.key === "ArrowRight" ? 16 : -16));
}}
/>
)}
<main
id="main-content"
tabIndex={-1}
className="min-h-screen min-w-0 pb-6 transition-[margin-left,width] duration-200 outline-none"
className={`min-h-screen min-w-0 pb-6 duration-200 outline-none ${
resizing ? "" : "transition-[margin-left,width]"
}`}
style={{
marginLeft: sidebarWidth,
width: `calc(100% - ${sidebarWidth})`,
+48 -3
View File
@@ -1,7 +1,8 @@
/**
* @file RemoteSources.tsx
* @description Settings UI for the Remote Data Sources feature: manage the SSH
* machines this dashboard pulls Claude Code history from, and choose the global
* machines this dashboard pulls Claude Code (and, opted in per source, Codex)
* history from, and choose the global
* "data scope" (which machines' data the whole app shows).
*
* Backs `server/routes/remote-sources.js` via {@link api.remoteSources} and the
@@ -97,6 +98,8 @@ const EMPTY_FORM: RemoteSourceInput = {
ssh_port: null,
identity_file: "",
remote_home: "",
sync_codex: false,
remote_codex_home: "",
enabled: true,
};
@@ -219,6 +222,8 @@ export function RemoteSources() {
ssh_port: s.ssh_port,
identity_file: s.identity_file || "",
remote_home: s.remote_home || "",
sync_codex: !!s.sync_codex,
remote_codex_home: s.remote_codex_home || "",
enabled: s.enabled,
});
setEditingId(s.id);
@@ -242,6 +247,8 @@ export function RemoteSources() {
ssh_port: form.ssh_port ? Number(form.ssh_port) : null,
identity_file: form.identity_file?.trim() ? form.identity_file.trim() : null,
remote_home: form.remote_home?.trim() ? form.remote_home.trim() : null,
sync_codex: !!form.sync_codex,
remote_codex_home: form.remote_codex_home?.trim() ? form.remote_codex_home.trim() : null,
enabled: form.enabled,
};
try {
@@ -591,6 +598,31 @@ export function RemoteSources() {
</p>
</div>
</div>
<Checkbox
checked={!!form.sync_codex}
onChange={(v) => setForm((f) => ({ ...f, sync_codex: v }))}
label={t("remoteSources.fieldSyncCodex", "Also pull Codex sessions")}
labelClassName="text-sm text-fg-secondary"
/>
{form.sync_codex && (
<div>
<label className="block text-xs text-fg-muted mb-1">
{t("remoteSources.fieldRemoteCodexHome", "Remote Codex home (optional)")}
</label>
<input
className="input w-full font-mono"
placeholder="~/.codex"
value={form.remote_codex_home ?? ""}
onChange={(e) => setForm((f) => ({ ...f, remote_codex_home: e.target.value }))}
/>
<p className="mt-1 text-[11px] text-fg-muted leading-snug">
{t(
"remoteSources.fieldRemoteCodexHomeHint",
"Default ~/.codex (wsl:~/.codex when the Claude home is in WSL). Its sessions/ folder is copied, and the sessions show up as Codex sessions of this source, with their tokens and cost."
)}
</p>
</div>
)}
<Checkbox
checked={!!form.enabled}
onChange={(v) => setForm((f) => ({ ...f, enabled: v }))}
@@ -674,6 +706,9 @@ export function RemoteSources() {
{s.host}
{s.ssh_port ? `:${s.ssh_port}` : ""}
{s.remote_home ? ` · ${s.remote_home}` : ""}
{s.sync_codex
? ` · ${t("remoteSources.codexOn", "+ Codex")}${s.remote_codex_home ? ` (${s.remote_codex_home})` : ""}`
: ""}
</div>
<div className="text-[11px] text-fg-muted mt-1">
{s.last_sync_at
@@ -689,9 +724,19 @@ export function RemoteSources() {
` · ${t("remoteSources.syncOnRemote", "{{n}} on remote", {
n: s.last_sync_counts.sessions_tagged,
})}`}
{s.last_sync_counts?.codex_sessions != null &&
` · ${t("remoteSources.syncCodex", "{{n}} Codex", {
n: s.last_sync_counts.codex_sessions,
})}`}
</div>
{s.status === "error" && s.last_error && (
<div className="text-[11px] text-status-danger mt-1 break-words">
{/* A sync that pulled one CLI's history but not the other's is
`ok` with the other's failure in last_error. */}
{s.last_error && (s.status === "error" || s.status === "ok") && (
<div
className={`text-[11px] mt-1 break-words ${
s.status === "error" ? "text-status-danger" : "text-status-warning"
}`}
>
{s.last_error}
</div>
)}
+41 -8
View File
@@ -5,7 +5,10 @@
* animation state (idle, run right, run left, waving, jumping, failed,
* waiting, running, review) — played one row at a time by Sagi's mood; an
* uploaded image is shown still and bobs with the same `data-mood` CSS as
* Sagi. Reduced motion holds the row's first frame. A sprite that fails to
* Sagi. An `action` (dragged left/right, hovered) plays the run-left/run-right
* or waving row over the mood's, like Codex's own pet. Each row steps at its
* own pace and holds its last frame longer, so a loop breathes instead of
* spinning. Reduced motion holds the row's first frame. A sprite that fails to
* load (deleted from another browser) calls `onBroken`, so the mascot falls
* back to Sagi instead of an empty box.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -30,11 +33,28 @@ const ROW: Record<Mood, number> = {
worried: 5, // failed
disconnected: 5,
};
/** What the user is doing to the pet, which outranks its mood. */
export type PetAction = "run-left" | "run-right" | "wave";
const ACTION_ROW: Record<PetAction, number> = { "run-right": 1, "run-left": 2, wave: 3 };
/** Per row: ms per frame, and ms the last frame is held before the loop restarts. */
const PACE: [number, number][] = [
[220, 1400], // idle: a breath, then a pause
[120, 120], // run right — keeps up with the drag
[120, 120], // run left
[180, 400], // waving
[160, 700], // jumping
[200, 1200], // failed
[220, 900], // waiting
[170, 400], // running (working)
[220, 900], // review
];
const SLEEP_PACE: [number, number] = [450, 2000];
export function PetSprite({
id,
kind,
mood,
action,
size,
reducedMotion,
onBroken,
@@ -42,23 +62,36 @@ export function PetSprite({
id: string;
kind: "codex" | "image";
mood: Mood;
action?: PetAction | null;
size: number;
reducedMotion: boolean;
onBroken: () => void;
}) {
const url = api.pets.spriteUrl(id);
const row = ROW[mood] ?? 0;
const row = action ? ACTION_ROW[action] : (ROW[mood] ?? 0);
const sleeping = !action && mood === "sleeping";
const [frame, setFrame] = useState(0);
useEffect(() => {
setFrame(0);
if (kind !== "codex" || reducedMotion) return;
const timer = window.setInterval(
() => setFrame((f) => (f + 1) % (FRAMES[row] ?? 1)),
mood === "sleeping" ? 400 : 125
);
return () => window.clearInterval(timer);
}, [kind, row, mood, reducedMotion]);
const count = FRAMES[row] ?? 1;
const [step, hold] = sleeping ? SLEEP_PACE : (PACE[row] ?? [200, 200]);
let f = 0;
let timer = 0;
const tick = () => {
timer = window.setTimeout(
() => {
f = (f + 1) % count;
setFrame(f);
tick();
},
f === count - 1 ? hold : step
);
};
tick();
return () => window.clearTimeout(timer);
}, [kind, row, sleeping, reducedMotion]);
// A background image reports no error of its own: probe it once.
useEffect(() => {
+5
View File
@@ -203,6 +203,7 @@ export function Sagi() {
const [open, setOpen] = useState(false);
// The right-click pet menu (`PetMenu.tsx`).
const [menu, setMenu] = useState(false);
const [hover, setHover] = useState(false);
const reducedMotion = usePrefersReducedMotion();
const navigate = useNavigate();
const brain = useSagiBrain();
@@ -331,6 +332,8 @@ export function Sagi() {
className="sagi-avatar-btn"
data-dragging={place.dragging ? "1" : "0"}
style={{ left: place.left, top: place.top, width: size, height: size }}
onPointerEnter={() => setHover(true)}
onPointerLeave={() => setHover(false)}
onPointerDown={place.onPointerDown}
onPointerMove={place.onPointerMove}
onPointerUp={place.onPointerUp}
@@ -358,6 +361,8 @@ export function Sagi() {
id={pet.id}
kind={pet.kind}
mood={brain.mood}
// Dragged → runs that way; hovered → waves (Codex's pet does both).
action={place.moving ? `run-${place.moving}` : hover ? "wave" : null}
size={size}
reducedMotion={reducedMotion}
// Deleted elsewhere, or unreadable: back to Sagi.
@@ -94,6 +94,8 @@ describe("PetSprite", () => {
expect(sprite.dataset.row).toBe("7");
expect(sprite.style.backgroundPosition).toBe("0px -728px");
act(() => vi.advanceTimersByTime(130));
expect(sprite.style.backgroundPosition).toBe("0px -728px"); // slower than 125 ms
act(() => vi.advanceTimersByTime(50));
expect(sprite.style.backgroundPosition).toBe("-96px -728px");
rerender(
<PetSprite
@@ -108,6 +110,45 @@ describe("PetSprite", () => {
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("5");
});
it("runs the way it is dragged and waves on hover, over its mood", () => {
const props = {
id: "cp-goku",
kind: "codex" as const,
mood: "thinking" as const,
size: 208,
reducedMotion: false,
onBroken: () => {},
};
const { rerender } = render(<PetSprite {...props} action="run-left" />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("2");
rerender(<PetSprite {...props} action="run-right" />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("1");
rerender(<PetSprite {...props} action="wave" />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("3");
rerender(<PetSprite {...props} action={null} />);
expect(screen.getByTestId("pet-sprite").dataset.row).toBe("7");
});
it("holds a loop's last frame before starting over", () => {
render(
<PetSprite
id="cp-goku"
kind="codex"
mood="idle"
size={208}
reducedMotion={false}
onBroken={() => {}}
/>
);
const sprite = screen.getByTestId("pet-sprite");
act(() => vi.advanceTimersByTime(5 * 220)); // idle: 6 frames → on the last one
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
act(() => vi.advanceTimersByTime(1000));
expect(sprite.style.backgroundPosition).toBe("-960px 0px");
act(() => vi.advanceTimersByTime(400));
expect(sprite.style.backgroundPosition).toBe("0px 0px");
});
it("holds the first frame under reduced motion", () => {
render(
<PetSprite
@@ -5,7 +5,16 @@
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { render, screen, fireEvent, act, cleanup, within } from "@testing-library/react";
import {
render,
renderHook,
screen,
fireEvent,
act,
cleanup,
within,
} from "@testing-library/react";
import { useSagiPosition } from "../useSagiPosition";
import { MemoryRouter } from "react-router-dom";
import { Sagi } from "../Sagi";
import { eventBus } from "../../../lib/eventBus";
@@ -192,3 +201,22 @@ describe("Sagi widget", () => {
expect(JSON.parse(localStorage.getItem("agent-dashboard-sagi-pos") || "{}").side).toBe("left");
});
});
describe("useSagiPosition moving", () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it("names the drag's direction and settles once the pointer rests", () => {
const { result } = renderHook(() => useSagiPosition(68));
const target = { setPointerCapture() {}, releasePointerCapture() {} };
const ev = (x: number) =>
({ button: 0, pointerId: 1, clientX: x, clientY: 300, currentTarget: target }) as never;
act(() => result.current.onPointerDown(ev(500)));
act(() => result.current.onPointerMove(ev(480)));
expect(result.current.moving).toBe("left");
act(() => result.current.onPointerMove(ev(520)));
expect(result.current.moving).toBe("right");
act(() => vi.advanceTimersByTime(400));
expect(result.current.moving).toBeNull();
});
});
+30 -3
View File
@@ -7,6 +7,8 @@
* viewport fraction so it survives resizes). A small movement threshold tells
* a drag apart from a tap so dragging never opens the panel. The arrow keys
* move a focused avatar too (WCAG 2.5.7): ←/→ dock it to an edge, ↑/↓ step it.
* `moving` names the way it is heading — while dragged (cleared once the
* pointer rests) and during the glide to its edge — so a pet can run that way.
* It never rests over the fixed bottom status bar; by default it docks
* bottom-right just above it.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
@@ -83,6 +85,8 @@ const DRAG_THRESHOLD = 5;
const STATUS_BAR = 24;
// One arrow-key step, in px.
const KEY_STEP = 40;
// A held but unmoving drag stops running after this; also the snap glide's length.
const MOVE_SETTLE_MS = 300;
const vw = () => (typeof window !== "undefined" ? window.innerWidth : 1024);
const vh = () => (typeof window !== "undefined" ? window.innerHeight : 768);
@@ -109,6 +113,8 @@ export interface SagiPlacement {
/** True when the avatar sits in the lower half - flyouts open upward. */
openUp: boolean;
dragging: boolean;
/** The way the avatar is moving horizontally right now, if it is. */
moving: "left" | "right" | null;
onPointerDown: (e: ReactPointerEvent) => void;
onPointerMove: (e: ReactPointerEvent) => void;
onPointerUp: (e: ReactPointerEvent) => void;
@@ -123,6 +129,15 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
const [pos, setPos] = useState<SagiPos>(() => sagiPrefs.getPos() ?? defaultPos());
const [drag, setDrag] = useState<{ left: number; top: number } | null>(null);
const [, force] = useState(0); // re-derive resting coords on resize
const [moving, setMoving] = useState<"left" | "right" | null>(null);
const moveTimer = useRef(0);
const lastX = useRef(0);
const heading = useCallback((dir: "left" | "right" | null) => {
window.clearTimeout(moveTimer.current);
setMoving(dir);
if (dir) moveTimer.current = window.setTimeout(() => setMoving(null), MOVE_SETTLE_MS);
}, []);
useEffect(() => () => window.clearTimeout(moveTimer.current), []);
const draggedRef = useRef(false);
const startRef = useRef<{ px: number; py: number; left: number; top: number } | null>(null);
@@ -152,6 +167,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
/* capture unsupported - window-free fallback still works via props */
}
startRef.current = { px: e.clientX, py: e.clientY, left: screen.left, top: screen.top };
lastX.current = e.clientX;
movedRef.current = false;
},
[screen.left, screen.top]
@@ -172,8 +188,14 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
);
liveRef.current = { left, top };
setDrag({ left, top });
// Ignore a pixel of jitter; a steady push keeps re-arming the settle timer.
const stepX = e.clientX - lastX.current;
if (Math.abs(stepX) >= 2) {
lastX.current = e.clientX;
heading(stepX > 0 ? "right" : "left");
}
},
[size]
[size, heading]
);
const onPointerUp = useCallback(
@@ -193,12 +215,15 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
sagiPrefs.setPos(next);
setPos(next);
setDrag(null); // leave drag mode; resting coords (with transition) take over
// Run along the glide to the edge, when there is a real distance to cover.
const glide = restingScreen(next, size).left - live.left;
heading(Math.abs(glide) > size / 2 ? (glide > 0 ? "right" : "left") : null);
}
liveRef.current = null;
startRef.current = null;
movedRef.current = false;
},
[size]
[size, heading]
);
const onKeyDown = useCallback(
@@ -214,10 +239,11 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
}
if (!next) return;
e.preventDefault();
if (next.side !== pos.side) heading(next.side);
sagiPrefs.setPos(next);
setPos(next);
},
[pos, size]
[pos, size, heading]
);
const consumeDrag = useCallback(() => {
@@ -233,6 +259,7 @@ export function useSagiPosition(size: number = SAGI_SIZE): SagiPlacement {
side: pos.side,
openUp: screen.top + size / 2 > vh() / 2,
dragging: drag !== null,
moving,
onPointerDown,
onPointerMove,
onPointerUp,
+9 -4
View File
@@ -76,6 +76,8 @@ import type { Agent, Session, SessionStats } from "../lib/types";
interface SessionOverviewProps {
session: Session;
agents: Agent[];
/** Frozen stats (a session snapshot): shown as given, never fetched or refreshed. */
stats?: SessionStats;
}
/** Throttle window for stats refresh - coalesces bursts of hook events into one fetch. */
@@ -160,18 +162,19 @@ function ToolUsageRow({ toolName, count, max }: { toolName: string; count: numbe
);
}
export function SessionOverview({ session, agents }: SessionOverviewProps) {
export function SessionOverview({ session, agents, stats: frozen }: SessionOverviewProps) {
const { t } = useTranslation("sessions");
const [stats, setStats] = useState<SessionStats | null>(null);
const [fetched, setStats] = useState<SessionStats | null>(null);
const stats = frozen ?? fetched;
const fetchingRef = useRef(false);
// Tick clock every 30s so a still-active session's "duration" tile stays current.
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (session.status !== "active") return;
if (frozen || session.status !== "active") return;
const id = window.setInterval(() => setNow(Date.now()), 30_000);
return () => window.clearInterval(id);
}, [session.status]);
}, [frozen, session.status]);
// A refresh asked for while one is in flight is not dropped: it runs once
// when the current one settles (that is the throttle's trailing call).
@@ -198,6 +201,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// Initial load + reload when the session id changes.
useEffect(() => {
if (frozen) return;
fetchStats();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [session.id]);
@@ -206,6 +210,7 @@ export function SessionOverview({ session, agents }: SessionOverviewProps) {
// stream still refreshes) and again after a reconnect.
useLiveRefresh(() => void fetchStats(), {
throttleMs: REFRESH_THROTTLE_MS,
enabled: !frozen,
match: (msg) => {
if (isRemoteDataRefreshMessage(msg)) return true;
if (
+10 -1
View File
@@ -69,6 +69,7 @@ import {
ChevronUp,
ChevronDown,
FolderTree,
TableProperties,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { api } from "../lib/api";
@@ -83,6 +84,7 @@ function isUpdatePayload(x: unknown): x is UpdateStatusPayload {
const NAV_KEYS = [
{ to: "/", icon: LayoutDashboard, key: "nav:dashboard" },
{ to: "/sessions", icon: FolderOpen, key: "nav:sessions" },
{ to: "/query", icon: TableProperties, key: "nav:query" },
] as const;
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
@@ -170,6 +172,10 @@ interface SidebarProps {
onUpdateAvailableChange?: (available: boolean) => void;
/** Set on a narrow viewport: the sidebar is an off-canvas drawer. */
drawer?: { open: boolean; onClose: () => void };
/** Expanded width in px, chosen by dragging its edge (see Layout). */
width?: number;
/** True while that edge is dragged: no width transition lagging the pointer. */
resizing?: boolean;
}
export function Sidebar({
@@ -180,6 +186,8 @@ export function Sidebar({
onConnectionOpenChange,
onUpdateAvailableChange,
drawer,
width = 240,
resizing = false,
}: SidebarProps) {
const { t } = useTranslation();
const asideRef = useRef<HTMLElement>(null);
@@ -423,8 +431,9 @@ export function Sidebar({
? "visible translate-x-0 shadow-2xl transition-transform"
: "invisible -translate-x-full transition-[transform,visibility]"
}`
: `z-30 transition-[width] duration-200 ${collapsed ? "w-[4.25rem]" : "w-60"}`
: `z-30 duration-200 ${resizing ? "" : "transition-[width]"} ${collapsed ? "w-[4.25rem]" : ""}`
}`}
style={drawer || collapsed ? undefined : { width }}
>
<div className="px-3 py-4 border-b border-border flex-shrink-0">
<div className="flex flex-col items-center justify-center">
+84
View File
@@ -0,0 +1,84 @@
/**
* @file SnapshotButton.tsx
* @description The session analysis page's **Snapshot** action: confirms with a
* "redact file paths" choice, freezes the session through `POST
* /api/snapshots`, copies the `/snapshot/<token>` link and offers it inline.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useState } from "react";
import { Link } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Camera } from "lucide-react";
import { api } from "../lib/api";
import { ConfirmModal } from "./ConfirmModal";
import { Checkbox } from "./Checkbox";
import { CopyButton } from "./event-views/primitives";
export function snapshotUrl(token: string): string {
return `${window.location.origin}/snapshot/${token}`;
}
export function SnapshotButton({ sessionId }: { sessionId: string }) {
const { t } = useTranslation("sessions");
const [open, setOpen] = useState(false);
const [redact, setRedact] = useState(true);
const [busy, setBusy] = useState(false);
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
const create = async () => {
setBusy(true);
setError(null);
try {
const { snapshot } = await api.snapshots.create(sessionId, { filePaths: redact });
setToken(snapshot.token);
navigator.clipboard?.writeText(snapshotUrl(snapshot.token)).catch(() => undefined);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
setOpen(false);
}
};
return (
<div className="flex flex-wrap items-center justify-end gap-1">
{token && (
<span className="inline-flex items-center gap-1 text-xs text-fg-secondary">
<Link to={`/snapshot/${token}`} className="text-accent hover:underline">
{t("snapshot.open")}
</Link>
<CopyButton text={snapshotUrl(token)} />
</span>
)}
<button
type="button"
onClick={() => setOpen(true)}
title={t("snapshot.hint")}
className="btn-ghost text-xs"
>
<Camera className="h-4 w-4" />
{t("snapshot.create")}
</button>
{error && (
<p role="alert" className="basis-full text-right text-[11px] text-status-danger">
{error}
</p>
)}
<ConfirmModal
open={open}
title={t("snapshot.title")}
message={t("snapshot.body")}
confirmLabel={t("snapshot.confirm")}
cancelLabel={t("snapshot.cancel")}
destructive={false}
busy={busy}
onConfirm={() => void create()}
onCancel={() => setOpen(false)}
>
<Checkbox checked={redact} onChange={setRedact} label={t("snapshot.redactPaths")} />
</ConfirmModal>
</div>
);
}
+25 -2
View File
@@ -23,10 +23,10 @@
import { Fragment, useCallback, useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useTranslation } from "react-i18next";
import { useLocation } from "react-router-dom";
import { Link, useLocation } from "react-router-dom";
import { RefreshCw, RotateCcw, Wifi, WifiOff } from "lucide-react";
import { api, type RunHandle } from "../lib/api";
import type { CostResult, ToolQuota } from "../lib/types";
import type { BudgetStatus, CostResult, ToolQuota } from "../lib/types";
import { fmt, fmtCostFull, formatModelName, timeAgo } from "../lib/format";
import { AiToolIcon } from "./AiToolIcon";
import { useLiveRefresh } from "../hooks/useLiveRefresh";
@@ -342,6 +342,8 @@ export function StatusBar({
const [laneCost, setLaneCost] = useState<CostResult | null>(null);
const [sessionCost, setSessionCost] = useState<CostResult | null>(null);
const [runs, setRuns] = useState<RunHandle[]>([]);
// The budget furthest past one of its thresholds, if any.
const [budget, setBudget] = useState<BudgetStatus | null>(null);
const location = useLocation();
const shown = selectedFrom(location.pathname, location.search);
const lane = shown.lane;
@@ -381,6 +383,13 @@ export function StatusBar({
.totalCost({ date: localDate() })
.then((r) => live && setToday(r))
.catch(() => undefined);
api.budgets
.status()
.then(({ budgets }) => {
const hit = budgets.filter((b) => b.crossed.length > 0).sort((a, b) => b.pct - a.pct);
if (live) setBudget(hit[0] ?? null);
})
.catch(() => undefined);
if (lane !== null) {
api.pricing
.totalCost({ lane })
@@ -470,6 +479,20 @@ export function StatusBar({
r={sessionCost}
/>
)}
{budget && (
<Link
to="/?tab=analytics&sub=cost"
data-testid="status-bar-budget"
title={t("statusBar.budgetTitle", {
name: budget.name,
spend: usd(budget.spend),
limit: usd(budget.config.limit_usd ?? 0),
})}
className={`tabular-nums hover:underline ${budget.pct >= 100 ? "text-status-danger" : "text-status-warning"}`}
>
{t("statusBar.budget", { pct: Math.round(budget.pct) })}
</Link>
)}
<QuotaChips />
</footer>
);
@@ -0,0 +1,102 @@
/**
* @file Tests for the alert rule form's spend rules: a budget rule is built
* from period / limit / thresholds / AI tool (plus the browser's timezone
* offset), Preview shows what it would have fired without saving it, and
* Create saves the same config.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi } from "vitest";
import { render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import userEvent from "@testing-library/user-event";
vi.mock("../../lib/api", () => ({
api: {
alerts: {
list: vi.fn().mockResolvedValue({ alerts: [], total: 0, unacked: 0, limit: 50, offset: 0 }),
rules: {
list: vi.fn().mockResolvedValue({ rules: [] }),
create: vi.fn().mockResolvedValue({ rule: {} }),
preview: vi.fn().mockResolvedValue({
supported: true,
window_days: 7,
fires: 1,
sessions: 0,
samples: [],
periods: [
{
period_start: "2026-09-01",
period_end: "2026-09-30",
spend: 120,
pct: 120,
crossed: [80, 100],
},
{
period_start: "2026-10-01",
period_end: "2026-10-31",
spend: 10,
pct: 10,
crossed: [],
},
],
}),
},
},
aiTools: {
list: vi.fn().mockResolvedValue({
tools: [{ id: "codex", label: "Codex" }],
default: "claude",
}),
},
},
}));
import { api } from "../../lib/api";
import { AlertsNotifications } from "../AlertsNotifications";
describe("AlertsNotifications — spend rules", () => {
it("previews and creates a budget rule", async () => {
const user = userEvent.setup();
render(
<MemoryRouter>
<AlertsNotifications />
</MemoryRouter>
);
await user.click(await screen.findByText("Add rule"));
await user.type(screen.getByPlaceholderText("e.g. Error burst"), "Monthly");
const selects = screen.getAllByRole("combobox");
await user.selectOptions(selects[0]!, "budget");
await user.selectOptions(screen.getByDisplayValue("month"), "week");
const limit = screen.getByDisplayValue("100");
await user.clear(limit);
await user.type(limit, "50");
await screen.findByRole("option", { name: "Codex" });
await user.selectOptions(screen.getByDisplayValue("All AI tools"), "codex");
await user.click(screen.getByRole("button", { name: /Preview/ }));
const config = {
period: "week",
limit_usd: 50,
thresholds: [80, 100],
tz_offset: new Date().getTimezoneOffset(),
tool: "codex",
};
expect(api.alerts.rules.preview).toHaveBeenCalledWith({
rule_type: "budget",
config,
cooldown_seconds: 300,
});
expect(await screen.findByText("2026-09-01")).toBeInTheDocument();
expect(screen.getByText("80% 100%")).toBeInTheDocument();
expect(api.alerts.rules.create).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: /Create rule/ }));
expect(api.alerts.rules.create).toHaveBeenCalledWith({
name: "Monthly",
rule_type: "budget",
config,
cooldown_seconds: 300,
});
});
});
@@ -0,0 +1,93 @@
/**
* @file EventReplay.test.tsx
* @description The Replay tab's controls: step, jump, arrow keys, a lifecycle
* marker jumping the cursor, playback advancing on a timer, and the derived
* state following the cursor.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { describe, it, expect, vi, afterEach } from "vitest";
import { act, fireEvent, render, screen } from "@testing-library/react";
import { MemoryRouter } from "react-router-dom";
import { EventReplay } from "../EventReplay";
import type { DashboardEvent } from "../../lib/types";
function ev(id: number, event_type: string, tool_name: string | null = null): DashboardEvent {
return {
id,
session_id: "s1",
agent_id: "a1",
event_type,
tool_name,
summary: `${event_type} ${tool_name ?? ""}`.trim(),
data: null,
created_at: new Date(Date.UTC(2026, 9, 1, 10, 0, id)).toISOString(),
} as DashboardEvent;
}
// Given newest first, as the events API answers: the replay orders them itself.
const events = [
ev(4, "Stop"),
ev(3, "PostToolUse", "Bash"),
ev(2, "PreToolUse", "Bash"),
ev(1, "SessionStart"),
];
function renderReplay() {
return render(
<MemoryRouter>
<EventReplay events={events} agentInfoById={new Map()} sessionNameById={new Map()} />
</MemoryRouter>
);
}
const position = () => screen.getByText(/^\d+ \/ 4/);
afterEach(() => vi.useRealTimers());
describe("EventReplay", () => {
it("starts at the first event and steps with the buttons", () => {
renderReplay();
expect(position().textContent).toMatch(/^1 \/ 4/);
fireEvent.click(screen.getByTitle("Step forward (→)"));
fireEvent.click(screen.getByTitle("Step forward (→)"));
expect(position().textContent).toMatch(/^3 \/ 4/);
expect(screen.getByText("Bash ×1")).toBeTruthy();
fireEvent.click(screen.getByTitle("Jump to end (End)"));
expect(position().textContent).toMatch(/^4 \/ 4/);
fireEvent.click(screen.getByTitle("Jump to start (Home)"));
expect(position().textContent).toMatch(/^1 \/ 4/);
});
it("moves on arrow keys and jumps from a lifecycle marker", () => {
renderReplay();
const root = screen.getByTestId("event-replay");
fireEvent.keyDown(root, { key: "ArrowRight" });
expect(position().textContent).toMatch(/^2 \/ 4/);
// The in-flight tool at the PreToolUse.
expect(screen.getByText("Running tool").nextSibling?.textContent).toBe("Bash");
fireEvent.keyDown(root, { key: "End" });
expect(position().textContent).toMatch(/^4 \/ 4/);
fireEvent.click(screen.getByRole("button", { name: "Session start · #1" }));
expect(position().textContent).toMatch(/^1 \/ 4/);
});
it("plays to the end at the chosen speed", () => {
vi.useFakeTimers();
renderReplay();
fireEvent.click(screen.getByRole("button", { name: "5×" }));
fireEvent.click(screen.getByTitle("Play (Space)"));
act(() => {
vi.advanceTimersByTime(100);
});
expect(position().textContent).toMatch(/^2 \/ 4/);
// One step per tick: each step schedules the next.
for (let i = 0; i < 3; i++) {
act(() => {
vi.advanceTimersByTime(100);
});
}
expect(position().textContent).toMatch(/^4 \/ 4/);
expect(screen.getByTitle("Play (Space)")).toBeTruthy();
});
});
@@ -3,7 +3,8 @@
* @description Responsive shell: with no stored preference the sidebar starts
* collapsed below `lg`, and below `md` it becomes an off-canvas drawer opened
* from a menu button — a modal dialog that Escape and the backdrop close,
* handing focus back to the button.
* handing focus back to the button. The expanded sidebar's edge resizes it
* within its bounds and remembers the width.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
@@ -16,7 +17,7 @@ vi.mock("../UpdateNotifier", () => ({ UpdateNotifier: () => null }));
vi.mock("../StatusBar", () => ({ StatusBar: () => null }));
vi.mock("../WorkspaceTree", () => ({ WorkspaceTree: () => null }));
import { Layout, DRAWER_QUERY } from "../Layout";
import { Layout, DRAWER_QUERY, SIDEBAR_MIN, SIDEBAR_MAX } from "../Layout";
import { loadCollapsed, SIDEBAR_STORAGE_KEY } from "../Sidebar";
function viewport(width: number) {
@@ -34,6 +35,7 @@ function viewport(width: number) {
afterEach(() => {
vi.unstubAllGlobals();
localStorage.removeItem(SIDEBAR_STORAGE_KEY);
localStorage.removeItem("agenthub.sidebar.width");
});
describe("loadCollapsed", () => {
@@ -95,3 +97,47 @@ describe("Layout drawer", () => {
expect(screen.getByRole("main").style.marginLeft).toBe("4.25rem");
});
});
describe("Layout sidebar width", () => {
it("the edge drags within bounds, is remembered, and a double-click resets it", () => {
viewport(1440);
vi.stubGlobal("innerWidth", 2560);
render(
<MemoryRouter>
<Layout wsConnected />
</MemoryRouter>
);
const edge = screen.getByTestId("sidebar-resize");
const main = screen.getByRole("main");
expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`);
fireEvent.pointerDown(edge, { pointerId: 1 });
fireEvent.pointerMove(edge, { clientX: 360 });
fireEvent.pointerUp(edge, { clientX: 360 });
expect(main.style.marginLeft).toBe("360px");
expect(screen.getByTestId("sidebar").style.width).toBe("360px");
expect(localStorage.getItem("agenthub.sidebar.width")).toBe("360");
fireEvent.pointerDown(edge, { pointerId: 1 });
fireEvent.pointerUp(edge, { clientX: 2000 });
expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX}px`);
fireEvent.keyDown(edge, { key: "ArrowLeft" });
expect(main.style.marginLeft).toBe(`${SIDEBAR_MAX - 16}px`);
fireEvent.doubleClick(edge);
expect(main.style.marginLeft).toBe(`${SIDEBAR_MIN}px`);
});
it("never wider than 40% of the window", () => {
viewport(1100);
vi.stubGlobal("innerWidth", 1100);
localStorage.setItem("agenthub.sidebar.width", "480");
render(
<MemoryRouter>
<Layout wsConnected />
</MemoryRouter>
);
expect(screen.getByRole("main").style.marginLeft).toBe("440px");
});
});
@@ -111,6 +111,21 @@ vi.mock("../../lib/api", () => ({
daily_costs: [],
})),
},
budgets: {
status: vi.fn().mockResolvedValue({
budgets: [
{ id: "b1", name: "Quiet", config: { limit_usd: 100 }, spend: 10, pct: 10, crossed: [] },
{
id: "b2",
name: "Monthly",
config: { limit_usd: 100 },
spend: 85,
pct: 85,
crossed: [80],
},
],
}),
},
},
}));
@@ -142,6 +157,14 @@ describe("StatusBar", () => {
expect(screen.getByRole("status")).toHaveTextContent("Live · 1 need you");
});
it("flags the budget furthest past a threshold, linking to the cost analytics", async () => {
at("/", <StatusBar wsConnected left="0" onConnectionClick={() => {}} />);
const chip = await screen.findByTestId("status-bar-budget");
expect(chip.textContent).toBe("Budget 85%");
expect(chip.getAttribute("href")).toBe("/?tab=analytics&sub=cost");
expect(chip.getAttribute("title")).toBe("Monthly: $85.00 of $100.00 this period");
});
it("says Disconnected when the socket is down", () => {
at("/", <StatusBar wsConnected={false} left="0" onConnectionClick={() => {}} />);
expect(screen.getByRole("button", { name: /Disconnected/ })).toBeInTheDocument();
+17 -1
View File
@@ -125,7 +125,23 @@ function HomeBody({
const entry = aiTools.byId(tool);
const claude = tool === "claude";
const codex = tool === "codex";
const [prompt, setPrompt] = useState("");
// The half-typed prompt survives leaving the page or the workspace.
const draftKey = `agenthub.workspace.draft.${lane.id}`;
const [prompt, setPrompt] = useState(() => {
try {
return localStorage.getItem(draftKey) ?? "";
} catch {
return "";
}
});
useEffect(() => {
try {
if (prompt) localStorage.setItem(draftKey, prompt);
else localStorage.removeItem(draftKey);
} catch {
/* storage unavailable: the draft just does not survive */
}
}, [draftKey, prompt]);
const [images, setImages] = useState<PromptImage[]>([]);
const [router, setRouter] = useSessionRouter();
const routers = useAiRouters();
@@ -193,7 +193,9 @@ describe("freeSlot / paneRunId", () => {
it("picks the first slot no live run of this lane holds", () => {
expect(freeSlot(1, [])).toBe(0);
expect(freeSlot(1, runs)).toBe(1);
const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map((slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle);
const all = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9].map(
(slot) => ({ laneId: 1, slot, status: "running" }) as RunHandle
);
expect(freeSlot(1, all)).toBeNull();
expect(freeSlot(1, all.slice(0, 4))).toBe(4);
expect(freeSlot(2, all)).toBe(0);
@@ -222,6 +224,23 @@ describe("WorkspaceHome", () => {
);
});
it("keeps a half-typed prompt per workspace across a remount, and drops it once sent", async () => {
const user = userEvent.setup();
const first = renderHome();
await screen.findByTestId("workspace-recent-s-live");
await user.type(screen.getByLabelText("How can I help you today?"), "long draft");
first.unmount();
const other = renderHome(lane({ id: 2 }));
await screen.findByTestId("workspace-recent-s-live");
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("");
other.unmount();
renderHome();
await screen.findByTestId("workspace-recent-s-live");
expect(screen.getByLabelText("How can I help you today?")).toHaveValue("long draft");
await user.click(screen.getByTestId("workspace-home-start"));
await waitFor(() => expect(localStorage.getItem("agenthub.workspace.draft.1")).toBeNull());
});
it("keeps AI and Model (with effort) in the box, Router/Permission/Worktree under it", async () => {
renderHome();
await screen.findByTestId("workspace-recent-s-live");
@@ -0,0 +1,111 @@
/**
* @file PrivacySettings.tsx
* @description Settings → Data → Privacy: the two opt-in ingest controls of
* `server/lib/privacy.js` — redact secrets (API keys, tokens, bearer headers,
* private keys, `password=…`) from the payloads and summaries AgentHub stores,
* and a list of folders whose hooks are dropped whole (one absolute path per
* line; a folder and everything under it). Both persist through
* `PUT /api/settings/privacy`; one the environment set is shown read-only.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, ShieldCheck } from "lucide-react";
import { api } from "../../lib/api";
import type { PrivacySettings as PrivacyState } from "../../lib/api";
import { SettingsSection, SettingsSwitchRow } from "./SettingsRow";
const lines = (text: string) =>
text
.split("\n")
.map((l) => l.trim())
.filter(Boolean);
export function PrivacySettings() {
const { t } = useTranslation("settings");
const [state, setState] = useState<PrivacyState | null>(null);
const [draft, setDraft] = useState("");
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const apply = (s: PrivacyState) => {
setState(s);
setDraft(s.ignore_cwd.join("\n"));
};
useEffect(() => {
api.settings.privacy
.get()
.then(apply)
.catch(() => undefined);
}, []);
async function save(patch: { redact_secrets?: boolean; ignore_cwd?: string[] }) {
setSaving(true);
setError(null);
try {
apply(await api.settings.privacy.save(patch));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setSaving(false);
}
}
const listChanged = !!state && lines(draft).join("\n") !== state.ignore_cwd.join("\n");
return (
<div id="privacy" className="scroll-mt-24">
<SettingsSection
title={
<span className="flex items-center gap-2">
<ShieldCheck className="w-4 h-4 text-fg-muted" />
{t("privacy.title")}
</span>
}
description={t("privacy.description")}
>
<SettingsSwitchRow
label={t("privacy.redact")}
description={t("privacy.redactHint")}
checked={!!state?.redact_secrets}
disabled={!state || saving || state.env_owned.redact_secrets}
onChange={(v) => void save({ redact_secrets: v })}
/>
<div className="py-3 space-y-2">
<label htmlFor="privacy-ignore" className="block text-sm text-fg-secondary">
{t("privacy.ignore")}
</label>
<p className="text-xs text-fg-muted">{t("privacy.ignoreHint")}</p>
<textarea
id="privacy-ignore"
className="input w-full font-mono text-xs min-h-[72px]"
placeholder={"/home/you/clients/secret-project\n~/private"}
value={draft}
disabled={!state || state.env_owned.ignore_cwd}
onChange={(e) => {
setDraft(e.target.value);
setError(null);
}}
/>
<button
type="button"
className="btn-primary px-4 py-2 text-sm disabled:opacity-50 disabled:cursor-not-allowed"
disabled={saving || !listChanged}
onClick={() => void save({ ignore_cwd: lines(draft) })}
>
{saving ? t("homeDir.saving") : t("homeDir.save")}
</button>
{error && <p className="text-xs text-status-danger">{error}</p>}
{(state?.env_owned.redact_secrets || state?.env_owned.ignore_cwd) && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("privacy.envOwned")}
</p>
)}
</div>
</SettingsSection>
</div>
);
}
@@ -0,0 +1,170 @@
/**
* @file PushIngestSettings.tsx
* @description Settings → Data → Push ingest: the bearer token that opens
* `POST /api/hooks/ingest-batch`, for a machine this dashboard cannot reach
* over SSH, so the machine pushes its hooks instead. Off until a token is
* created; the token is shown once, right after it is made (only its hash is
* stored), and can be rotated or revoked. Also shows a forwarder script for
* the other machine — its token in a header file, never on a command line.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { AlertTriangle, Copy, KeyRound, RefreshCw, Trash2 } from "lucide-react";
import { api } from "../../lib/api";
import type { PushTokenStatus } from "../../lib/api";
import { ConfirmModal } from "../ConfirmModal";
import { SettingsSection } from "./SettingsRow";
const copyText = (text: string) => navigator.clipboard?.writeText(text).catch(() => undefined);
/** The forwarder for the pushing machine; `origin` is how it reaches this dashboard. */
function forwarder(origin: string) {
return `#!/bin/sh
# ~/.claude/agenthub-push.sh <HookEvent> — a Claude Code hook command:
# "command": "sh ~/.claude/agenthub-push.sh PreToolUse"
# ~/.claude/agenthub-push.header (chmod 600) holds one line:
# Authorization: Bearer <token>
jq -c --arg t "$1" --arg s "$(hostname -s)" \\
'{source: $s, events: [{hook_type: $t, data: .}]}' |
curl -s -m 5 -o /dev/null -X POST "${origin}/api/hooks/ingest-batch" \\
-H "@$HOME/.claude/agenthub-push.header" \\
-H "Content-Type: application/json" --data-binary @-
exit 0`;
}
export function PushIngestSettings() {
const { t } = useTranslation("settings");
const [status, setStatus] = useState<PushTokenStatus | null>(null);
const [token, setToken] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
// Rotating a live token or revoking one asks first.
const [confirm, setConfirm] = useState<"rotate" | "revoke" | null>(null);
useEffect(() => {
api.settings.pushToken
.get()
.then(setStatus)
.catch(() => undefined);
}, []);
async function run(fn: () => Promise<void>) {
setConfirm(null);
setBusy(true);
setError(null);
try {
await fn();
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
}
}
const rotate = () =>
run(async () => {
const r = await api.settings.pushToken.rotate();
setToken(r.token);
setStatus({ enabled: r.enabled, env_owned: r.env_owned });
});
const revoke = () =>
run(async () => {
setStatus(await api.settings.pushToken.revoke());
setToken(null);
});
const script = forwarder(window.location.origin);
return (
<div id="push-ingest" className="scroll-mt-24">
<SettingsSection
title={
<span className="flex items-center gap-2">
<KeyRound className="w-4 h-4 text-fg-muted" />
{t("pushIngest.title")}
</span>
}
description={t("pushIngest.description")}
>
<div className="space-y-3 py-2">
<div className="flex flex-wrap items-center gap-2">
<span
className={`text-xs ${status?.enabled ? "text-status-success" : "text-fg-muted"}`}
>
{status?.enabled ? t("pushIngest.on") : t("pushIngest.off")}
</span>
<button
type="button"
className="btn-primary text-xs disabled:opacity-40"
disabled={busy || !status || status.env_owned}
onClick={() => (status?.enabled ? setConfirm("rotate") : void rotate())}
>
<RefreshCw className="w-3.5 h-3.5" />
{status?.enabled ? t("pushIngest.rotate") : t("pushIngest.create")}
</button>
{status?.enabled && (
<button
type="button"
className="btn-ghost text-xs text-status-danger disabled:opacity-40"
disabled={busy || status.env_owned}
onClick={() => setConfirm("revoke")}
>
<Trash2 className="w-3.5 h-3.5" />
{t("pushIngest.revoke")}
</button>
)}
</div>
{token && (
<div className="rounded-lg border border-status-warning/25 bg-status-warning/10 p-3 space-y-2">
<p className="text-xs text-status-warning">{t("pushIngest.shownOnce")}</p>
<div className="flex items-center gap-2">
<code className="flex-1 min-w-0 break-all text-xs text-fg-primary">{token}</code>
<button
type="button"
className="btn-ghost text-xs"
title={t("pushIngest.copy")}
onClick={() => void copyText(token)}
>
<Copy className="w-3.5 h-3.5" />
</button>
</div>
</div>
)}
{status?.env_owned && (
<p className="text-xs text-status-warning flex items-start gap-1.5">
<AlertTriangle className="w-3.5 h-3.5 flex-shrink-0 mt-px" />
{t("pushIngest.envOwned")}
</p>
)}
<p className="text-xs text-fg-muted">{t("pushIngest.reachHint")}</p>
<div className="relative">
<pre className="text-[11px] font-mono bg-surface-1 border border-border rounded-lg p-3 overflow-x-auto text-fg-secondary">
{script}
</pre>
<button
type="button"
className="btn-ghost text-xs absolute top-1.5 right-1.5"
title={t("pushIngest.copy")}
onClick={() => void copyText(script)}
>
<Copy className="w-3.5 h-3.5" />
</button>
</div>
<p className="text-xs text-fg-muted">{t("pushIngest.limits")}</p>
{error && <p className="text-xs text-status-danger">{error}</p>}
</div>
<ConfirmModal
open={confirm !== null}
title={t(confirm === "revoke" ? "pushIngest.revokeConfirm" : "pushIngest.rotateConfirm")}
confirmLabel={t(confirm === "revoke" ? "pushIngest.revoke" : "pushIngest.rotate")}
cancelLabel={t("common:cancel")}
destructive={confirm === "revoke"}
busy={busy}
onConfirm={() => void (confirm === "revoke" ? revoke() : rotate())}
onCancel={() => setConfirm(null)}
/>
</SettingsSection>
</div>
);
}
@@ -0,0 +1,394 @@
/**
* @file ReportSettings.tsx
* @description Settings → Reports: scheduled report definitions (`/api/reports`)
* — create/edit (name, window, daily/weekly + weekday, local hour, HTML/JSON),
* enable switch, Run now, a confirmed Delete, and each report's run history
* with download links. Every row action is also on a right-click menu. The
* browser's timezone offset is saved with the definition, so its hour is the
* user's local hour.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Download, History, Pencil, Play, Plus, Trash2 } from "lucide-react";
import { api } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import type { ReportDefinition, ReportInput, ReportRun } from "../../lib/types";
import { Checkbox } from "../Checkbox";
import { ConfirmModal } from "../ConfirmModal";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { Select } from "../Select";
import { Switch } from "../Switch";
import { SettingsSection } from "./SettingsRow";
const EMPTY: ReportInput = {
name: "",
window_days: 7,
frequency: "weekly",
hour: 8,
weekday: 1,
tz_offset: 0,
formats: ["html"],
enabled: true,
};
const HOURS = Array.from({ length: 24 }, (_, h) => h);
const pad = (n: number) => String(n).padStart(2, "0");
export function ReportSettings() {
const { t, i18n } = useTranslation("settings");
const [items, setItems] = useState<ReportDefinition[] | null>(null);
const [error, setError] = useState<string | null>(null);
/** The form: a new report (id null) or the one being edited. */
const [form, setForm] = useState<{ id: number | null; value: ReportInput } | null>(null);
const [deleting, setDeleting] = useState<ReportDefinition | null>(null);
const [busy, setBusy] = useState<number | "form" | null>(null);
const [history, setHistory] = useState<{ id: number; runs: ReportRun[] } | null>(null);
const ctx = useContextMenu<ReportDefinition>();
const weekday = (d: number) =>
new Intl.DateTimeFormat(i18n.resolvedLanguage ?? i18n.language, { weekday: "long" }).format(
new Date(Date.UTC(2026, 0, 4 + d))
);
const fail = (err: unknown) => setError(err instanceof Error ? err.message : String(err));
const load = useCallback(() => {
api.reports
.list()
.then((r) => setItems(r.reports))
.catch(fail);
}, []);
useEffect(load, [load]);
const showHistory = async (id: number) => {
try {
const r = await api.reports.runs(id);
setHistory({ id, runs: r.runs });
} catch (err) {
fail(err);
}
};
const save = async () => {
if (!form) return;
setBusy("form");
const value = { ...form.value, tz_offset: new Date().getTimezoneOffset() };
try {
if (form.id === null) await api.reports.create(value);
else await api.reports.update(form.id, value);
setForm(null);
setError(null);
load();
} catch (err) {
fail(err);
} finally {
setBusy(null);
}
};
const toggle = async (r: ReportDefinition, enabled: boolean) => {
try {
await api.reports.update(r.id, { enabled });
load();
} catch (err) {
fail(err);
}
};
const runNow = async (r: ReportDefinition) => {
setBusy(r.id);
try {
const { run } = await api.reports.run(r.id);
if (run.status === "error") setError(run.error);
load();
if (history?.id === r.id) await showHistory(r.id);
} catch (err) {
fail(err);
} finally {
setBusy(null);
}
};
const remove = async () => {
if (!deleting) return;
try {
await api.reports.remove(deleting.id);
if (history?.id === deleting.id) setHistory(null);
load();
} catch (err) {
fail(err);
} finally {
setDeleting(null);
}
};
const edit = (r: ReportDefinition) =>
setForm({
id: r.id,
value: {
name: r.name,
window_days: r.window_days,
frequency: r.frequency,
hour: r.hour,
weekday: r.weekday,
tz_offset: r.tz_offset,
formats: r.formats,
enabled: r.enabled,
},
});
const pick = (fn: (r: ReportDefinition) => void) => () => {
const r = ctx.menu?.item;
ctx.close();
if (r) fn(r);
};
const schedule = (r: ReportInput) =>
[
r.frequency === "daily"
? t("reports.everyDay", { time: `${pad(r.hour)}:00` })
: t("reports.everyWeek", { day: weekday(r.weekday), time: `${pad(r.hour)}:00` }),
t("reports.window", { count: r.window_days }),
r.formats.map((f) => f.toUpperCase()).join(", "),
].join(" · ");
const set = (patch: Partial<ReportInput>) =>
setForm((f) => (f ? { ...f, value: { ...f.value, ...patch } } : f));
const toggleFormat = (f: "html" | "json", on: boolean) =>
set({
formats: on
? [...(form?.value.formats ?? []), f]
: (form?.value.formats ?? []).filter((x) => x !== f),
});
return (
<SettingsSection title={t("reports.title")} description={t("reports.description")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{form ? (
<div
className="my-2 space-y-3 rounded-md border border-border p-3"
data-testid="report-form"
>
<label className="block text-xs text-fg-muted">
{t("reports.name")}
<input
className="input mt-1 w-full text-sm"
value={form.value.name}
maxLength={200}
onChange={(e) => set({ name: e.target.value })}
/>
</label>
<div className="flex flex-wrap items-end gap-3">
<label className="text-xs text-fg-muted">
{t("reports.windowDays")}
<input
type="number"
min={1}
max={366}
className="input mt-1 block w-24 text-sm"
value={form.value.window_days}
onChange={(e) => set({ window_days: Math.trunc(Number(e.target.value)) })}
/>
</label>
<div className="w-36">
<Select
value={form.value.frequency}
onChange={(v) => set({ frequency: v })}
label={t("reports.frequency")}
options={[
{ value: "daily", label: t("reports.daily") },
{ value: "weekly", label: t("reports.weekly") },
]}
/>
</div>
{form.value.frequency === "weekly" && (
<div className="w-40">
<Select
value={String(form.value.weekday)}
onChange={(v) => set({ weekday: Number(v) })}
label={t("reports.weekday")}
options={[1, 2, 3, 4, 5, 6, 0].map((d) => ({
value: String(d),
label: weekday(d),
}))}
/>
</div>
)}
<div className="w-28">
<Select
value={String(form.value.hour)}
onChange={(v) => set({ hour: Number(v) })}
label={t("reports.hour")}
options={HOURS.map((h) => ({ value: String(h), label: `${pad(h)}:00` }))}
/>
</div>
</div>
<div className="flex flex-wrap items-center gap-4">
<Checkbox
checked={form.value.formats.includes("html")}
onChange={(v) => toggleFormat("html", v)}
label="HTML"
/>
<Checkbox
checked={form.value.formats.includes("json")}
onChange={(v) => toggleFormat("json", v)}
label="JSON"
/>
</div>
<p className="text-[11px] text-fg-muted">{t("reports.windowHint")}</p>
<div className="flex gap-2">
<button
type="button"
className="btn-primary text-xs"
disabled={
busy === "form" || !form.value.name.trim() || form.value.formats.length === 0
}
onClick={() => void save()}
>
{t("reports.save")}
</button>
<button type="button" className="btn-ghost text-xs" onClick={() => setForm(null)}>
{t("reports.cancel")}
</button>
</div>
</div>
) : (
<button
type="button"
className="btn-ghost my-2 text-xs"
onClick={() => setForm({ id: null, value: { ...EMPTY, name: t("reports.defaultName") } })}
>
<Plus className="h-3.5 w-3.5" />
{t("reports.add")}
</button>
)}
{items?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("reports.empty")}</p>}
{items && items.length > 0 && (
<ul className="divide-y divide-border/60" data-testid="report-list">
{items.map((r) => (
<li key={r.id} onContextMenu={(e) => ctx.open(e, r)} className="py-2.5">
<div className="flex items-center gap-3">
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-fg-secondary">{r.name}</p>
<p className="text-[11px] text-fg-muted">{schedule(r)}</p>
{r.last_run && (
<p
className={`text-[11px] ${
r.last_run.status === "error" ? "text-status-danger" : "text-fg-muted"
}`}
>
{t("reports.lastRun", { at: formatDateTime(r.last_run.created_at) })}
{r.last_run.status === "error" && ` · ${r.last_run.error ?? ""}`}
</p>
)}
</div>
<Switch
checked={r.enabled}
onChange={(v) => void toggle(r, v)}
label={t("reports.enabled")}
title={t("reports.enabled")}
/>
<button
type="button"
className="btn-ghost text-xs"
disabled={busy === r.id}
onClick={() => void runNow(r)}
title={t("reports.runNow")}
aria-label={t("reports.runNow")}
>
<Play className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn-ghost text-xs"
onClick={() => (history?.id === r.id ? setHistory(null) : void showHistory(r.id))}
title={t("reports.history")}
aria-label={t("reports.history")}
>
<History className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn-ghost text-xs"
onClick={() => edit(r)}
title={t("reports.edit")}
aria-label={t("reports.edit")}
>
<Pencil className="h-3.5 w-3.5" />
</button>
<button
type="button"
className="btn-ghost text-xs text-status-danger"
onClick={() => setDeleting(r)}
title={t("reports.delete")}
aria-label={t("reports.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
{history?.id === r.id && (
<ul
className="mt-2 space-y-1 border-l border-border pl-3"
data-testid="report-runs"
>
{history.runs.length === 0 && (
<li className="text-[11px] text-fg-muted">{t("reports.noRuns")}</li>
)}
{history.runs.map((run) => (
<li key={run.id} className="flex flex-wrap items-center gap-2 text-[11px]">
<span className="text-fg-secondary">{formatDateTime(run.created_at)}</span>
<span className="text-fg-muted">
{t(`reports.trigger.${run.trigger}`)} · {run.window_from} → {run.window_to}
</span>
{run.status === "error" ? (
<span className="text-status-danger">{run.error}</span>
) : (
run.files.map((f) => (
<a
key={f}
href={api.reports.fileUrl(r.id, run.id, f)}
download
className="inline-flex items-center gap-0.5 text-accent hover:underline"
title={t("reports.download", { format: f.toUpperCase() })}
>
<Download className="h-3 w-3" />
{f.toUpperCase()}
</a>
))
)}
</li>
))}
</ul>
)}
</li>
))}
</ul>
)}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="report-menu">
<MenuItem onSelect={pick((r) => void runNow(r))}>{t("reports.runNow")}</MenuItem>
<MenuItem onSelect={pick((r) => void showHistory(r.id))}>{t("reports.history")}</MenuItem>
<MenuItem onSelect={pick(edit)}>{t("reports.edit")}</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pick(setDeleting)}>
{t("reports.delete")}
</MenuItem>
</ContextMenu>
)}
<ConfirmModal
open={deleting !== null}
title={t("reports.deleteTitle")}
message={t("reports.deleteBody")}
confirmLabel={t("reports.delete")}
cancelLabel={t("reports.cancel")}
onConfirm={() => void remove()}
onCancel={() => setDeleting(null)}
/>
</SettingsSection>
);
}
@@ -0,0 +1,142 @@
/**
* @file SnapshotSettings.tsx
* @description Settings → Snapshots: every frozen session (`GET
* /api/snapshots`), each with Open, Copy link and a confirmed Delete — the
* buttons and the same actions on a right-click menu. Snapshots survive Clear
* Data, so this is the one place they go away.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { useCallback, useEffect, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { ExternalLink, ShieldCheck, Trash2 } from "lucide-react";
import { api } from "../../lib/api";
import { formatDateTime } from "../../lib/format";
import type { SessionSnapshotInfo } from "../../lib/types";
import { ConfirmModal } from "../ConfirmModal";
import { ContextMenu, MenuItem, MenuSeparator, useContextMenu } from "../ContextMenu";
import { CopyButton } from "../event-views/primitives";
import { snapshotUrl } from "../SnapshotButton";
import { SettingsSection } from "./SettingsRow";
export function SnapshotSettings() {
const { t } = useTranslation("settings");
const [items, setItems] = useState<SessionSnapshotInfo[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [deleting, setDeleting] = useState<SessionSnapshotInfo | null>(null);
const [busy, setBusy] = useState(false);
const ctx = useContextMenu<SessionSnapshotInfo>();
const navigate = useNavigate();
const load = useCallback(() => {
api.snapshots
.list()
.then((r) => setItems(r.snapshots))
.catch((err) => setError(err instanceof Error ? err.message : String(err)));
}, []);
useEffect(load, [load]);
const remove = async () => {
if (!deleting) return;
setBusy(true);
try {
await api.snapshots.remove(deleting.id);
setItems((xs) => xs?.filter((x) => x.id !== deleting.id) ?? null);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setBusy(false);
setDeleting(null);
}
};
const pick = (fn: (s: SessionSnapshotInfo) => void) => () => {
const s = ctx.menu?.item;
ctx.close();
if (s) fn(s);
};
return (
<SettingsSection title={t("snapshots.title")} description={t("snapshots.description")}>
{error && (
<p role="alert" className="py-2 text-xs text-status-danger">
{error}
</p>
)}
{items?.length === 0 && <p className="py-2 text-sm text-fg-muted">{t("snapshots.empty")}</p>}
{items && items.length > 0 && (
<ul className="divide-y divide-border/60" data-testid="snapshot-list">
{items.map((s) => (
<li
key={s.id}
onContextMenu={(e) => ctx.open(e, s)}
className="flex items-center gap-3 py-2.5"
>
<div className="min-w-0 flex-1">
<p className="truncate text-sm text-fg-secondary">
{s.session_name || s.session_id.slice(0, 8)}
</p>
<p className="flex items-center gap-2 text-[11px] text-fg-muted">
<span>{formatDateTime(s.created_at)}</span>
<span>· {t("snapshots.events", { n: s.event_count ?? 0 })}</span>
{s.redacted && (
<span
className="inline-flex items-center gap-0.5"
title={t("snapshots.redacted")}
>
<ShieldCheck className="h-3 w-3" />
{t("snapshots.redacted")}
</span>
)}
</p>
</div>
<Link
to={`/snapshot/${s.token}`}
className="btn-ghost text-xs"
title={t("snapshots.open")}
>
<ExternalLink className="h-3.5 w-3.5" />
</Link>
<CopyButton text={snapshotUrl(s.token)} />
<button
type="button"
onClick={() => setDeleting(s)}
className="btn-ghost text-xs text-status-danger"
title={t("snapshots.delete")}
aria-label={t("snapshots.delete")}
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</li>
))}
</ul>
)}
{ctx.menu && (
<ContextMenu x={ctx.menu.x} y={ctx.menu.y} onClose={ctx.close} testId="snapshot-menu">
<MenuItem onSelect={pick((s) => navigate(`/snapshot/${s.token}`))}>
{t("snapshots.open")}
</MenuItem>
<MenuItem
onSelect={pick((s) => void navigator.clipboard?.writeText(snapshotUrl(s.token)))}
>
{t("snapshots.copyLink")}
</MenuItem>
<MenuSeparator />
<MenuItem danger onSelect={pick(setDeleting)}>
{t("snapshots.delete")}
</MenuItem>
</ContextMenu>
)}
<ConfirmModal
open={deleting !== null}
title={t("snapshots.deleteTitle")}
message={t("snapshots.deleteBody")}
confirmLabel={t("snapshots.delete")}
cancelLabel={t("snapshots.cancel")}
busy={busy}
onConfirm={() => void remove()}
onCancel={() => setDeleting(null)}
/>
</SettingsSection>
);
}
@@ -0,0 +1,61 @@
/**
* @file PushIngestSettings.test.tsx
* @description Settings → Data → Push ingest and Privacy: a created token is
* shown once (a reload shows only that push is on), and the ignored-folder
* list is saved one path per line with blank lines dropped.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
const { pushToken, privacy } = vi.hoisted(() => ({
pushToken: { get: vi.fn(), rotate: vi.fn(), revoke: vi.fn() },
privacy: { get: vi.fn(), save: vi.fn() },
}));
vi.mock("../../../lib/api", () => ({ api: { settings: { pushToken, privacy } } }));
import { PushIngestSettings } from "../PushIngestSettings";
import { PrivacySettings } from "../PrivacySettings";
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
});
describe("PushIngestSettings", () => {
it("shows a new token once, and never on a fresh load", async () => {
const user = userEvent.setup();
pushToken.get.mockResolvedValue({ enabled: false, env_owned: false });
pushToken.rotate.mockResolvedValue({ token: "ahp_secret", enabled: true, env_owned: false });
const view = render(<PushIngestSettings />);
await user.click(await screen.findByRole("button", { name: /Create token/ }));
expect(await screen.findByText("ahp_secret")).toBeInTheDocument();
view.unmount();
pushToken.get.mockResolvedValue({ enabled: true, env_owned: false });
render(<PushIngestSettings />);
expect(await screen.findByRole("button", { name: /Rotate token/ })).toBeInTheDocument();
expect(screen.queryByText("ahp_secret")).toBeNull();
});
});
describe("PrivacySettings", () => {
it("saves the ignored folders one per line", async () => {
const user = userEvent.setup();
const state = {
redact_secrets: false,
ignore_cwd: [],
env_owned: { redact_secrets: false, ignore_cwd: false },
};
privacy.get.mockResolvedValue(state);
privacy.save.mockImplementation((p) => Promise.resolve({ ok: true, ...state, ...p }));
render(<PrivacySettings />);
const box = await screen.findByLabelText("Ignored folders");
await user.type(box, "/a/b{enter}{enter}/c");
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() => expect(privacy.save).toHaveBeenCalledWith({ ignore_cwd: ["/a/b", "/c"] }));
});
});
@@ -0,0 +1,97 @@
/**
* @file ReportSettings.test.tsx
* @description Settings → Reports: a new report is saved with the browser's
* timezone offset, Run now runs it, and History lists the runs with download
* links built from the report and run ids.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import i18n from "i18next";
const { reports } = vi.hoisted(() => ({
reports: {
list: vi.fn(),
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
run: vi.fn(),
runs: vi.fn(),
fileUrl: (id: number, runId: number, f: string) => `/api/reports/${id}/runs/${runId}/${f}`,
},
}));
vi.mock("../../../lib/api", () => ({ api: { reports } }));
import { ReportSettings } from "../ReportSettings";
const DEF = {
id: 3,
name: "Weekly summary",
window_days: 7,
frequency: "weekly" as const,
hour: 8,
weekday: 1,
tz_offset: 0,
formats: ["html" as const, "json" as const],
enabled: true,
created_at: "2026-10-01T00:00:00Z",
last_slot: null,
last_run: null,
};
const RUN = {
id: 9,
report_id: 3,
created_at: "2026-10-05T01:00:00Z",
trigger: "manual" as const,
status: "ok" as const,
window_from: "2026-09-28",
window_to: "2026-10-04",
files: ["html", "json"],
error: null,
};
beforeEach(() => {
i18n.changeLanguage("en");
vi.clearAllMocks();
});
describe("ReportSettings", () => {
it("creates a report with the browser's timezone offset", async () => {
const user = userEvent.setup();
reports.list.mockResolvedValueOnce({ reports: [] }).mockResolvedValue({ reports: [DEF] });
reports.create.mockResolvedValue({ report: DEF });
render(<ReportSettings />);
await user.click(await screen.findByRole("button", { name: /New report/ }));
await user.click(screen.getByRole("checkbox", { name: "JSON" }));
await user.click(screen.getByRole("button", { name: "Save" }));
await waitFor(() =>
expect(reports.create).toHaveBeenCalledWith({
name: "Weekly summary",
window_days: 7,
frequency: "weekly",
hour: 8,
weekday: 1,
tz_offset: new Date().getTimezoneOffset(),
formats: ["html", "json"],
enabled: true,
})
);
expect(await screen.findByText(/last 7 days · HTML, JSON/)).toBeInTheDocument();
});
it("runs a report now and lists its runs with download links", async () => {
const user = userEvent.setup();
reports.list.mockResolvedValue({ reports: [DEF] });
reports.run.mockResolvedValue({ run: RUN });
reports.runs.mockResolvedValue({ runs: [RUN] });
render(<ReportSettings />);
await user.click(await screen.findByRole("button", { name: "Run now" }));
expect(reports.run).toHaveBeenCalledWith(3);
await user.click(screen.getByRole("button", { name: "History" }));
const link = await screen.findByRole("link", { name: /HTML/ });
expect(link).toHaveAttribute("href", "/api/reports/3/runs/9/html");
expect(screen.getByText(/2026-09-28 → 2026-10-04/)).toBeInTheDocument();
});
});