Files
nanobot/webui/src/components/thread/SessionInfoPopover.tsx
T
chengyongruandXubin Ren 2ebf5c4972 refactor(trigger): name CLI trigger source as local
maintainer edit: cron is also a trigger source, so keep the new CLI-delivered source explicitly named as local trigger across backend, WebUI, docs, and tests.
2026-07-02 13:32:46 +08:00

240 lines
8.1 KiB
TypeScript

import { useState } from "react";
import {
CalendarClock,
CircleAlert,
ListTodo,
RefreshCcw,
} from "lucide-react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { useSessionAutomationJobs } from "@/hooks/useSessionAutomationJobs";
import { currentLocale } from "@/i18n";
import { fmtDateTime } from "@/lib/format";
import type { SessionAutomationJob } from "@/lib/types";
import { cn } from "@/lib/utils";
const RELATIVE_THRESHOLDS: [number, Intl.RelativeTimeFormatUnit][] = [
[60, "second"],
[60, "minute"],
[24, "hour"],
[7, "day"],
[4.345, "week"],
[12, "month"],
[Number.POSITIVE_INFINITY, "year"],
];
interface SessionInfoPopoverProps {
sessionKey: string;
token: string;
title: string;
}
export function SessionInfoPopover({ sessionKey, token, title }: SessionInfoPopoverProps) {
const { t } = useTranslation("common");
const [open, setOpen] = useState(false);
const { jobs, loading, loadFailed, now } = useSessionAutomationJobs(open, token, sessionKey);
const automationContent = loading ? (
<div className="flex items-center gap-2 rounded-[16px] bg-muted/45 px-3 py-3 text-[12.5px] text-muted-foreground">
<RefreshCcw className="h-3.5 w-3.5 animate-spin" />
{t("thread.sessionInfo.loading")}
</div>
) : loadFailed ? (
<div className="flex items-center gap-2 rounded-[16px] bg-destructive/10 px-3 py-3 text-[12.5px] text-destructive">
<CircleAlert className="h-3.5 w-3.5" />
{t("thread.sessionInfo.loadFailed")}
</div>
) : jobs.length ? (
<div className="space-y-1.5">
{jobs.map((job) => (
<AutomationRow key={job.id} job={job} now={now} />
))}
</div>
) : (
<div className="rounded-[16px] bg-muted/35 px-3 py-3 text-[12.5px] leading-relaxed text-muted-foreground">
{t("thread.sessionInfo.empty")}
</div>
);
return (
<DropdownMenu modal={false} open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button
variant="ghost"
size="icon"
aria-label={t("thread.header.sessionInfo")}
className={cn(
"host-no-drag h-8 w-8 rounded-full text-muted-foreground/85",
"hover:bg-accent/40 hover:text-foreground",
)}
>
<ListTodo className="h-4 w-4 stroke-[1.75]" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent
align="end"
sideOffset={8}
className="w-[min(23rem,calc(100vw-1.5rem))] rounded-[24px] p-0"
>
<div className="space-y-3 px-4 py-3.5">
<div className="min-w-0">
<div className="text-[12px] font-normal text-muted-foreground/75">
{t("thread.sessionInfo.title")}
</div>
<div className="mt-0.5 truncate text-[14px] font-medium text-foreground">
{title || t("thread.sessionInfo.untitled")}
</div>
</div>
<div className="h-px bg-border/45" />
<div className="flex items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-2">
<CalendarClock className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" />
<span className="truncate text-[13px] font-medium text-foreground">
{t("thread.sessionInfo.automations")}
</span>
</div>
<span className="rounded-full bg-muted/70 px-2 py-0.5 text-[11px] text-muted-foreground">
{t("thread.sessionInfo.count", { count: jobs.length })}
</span>
</div>
{automationContent}
</div>
</DropdownMenuContent>
</DropdownMenu>
);
}
function AutomationRow({ job, now }: { job: SessionAutomationJob; now: number }) {
const { t } = useTranslation("common");
const schedule = formatSchedule(job, t);
const nextRun = formatNextRun(job, t, now);
const statusClass = job.enabled
? job.state.last_status === "error"
? "bg-destructive"
: "bg-emerald-500"
: "bg-muted-foreground/35";
return (
<div className="rounded-[16px] px-3 py-2.5 transition-colors hover:bg-muted/40">
<div className="flex items-start gap-2.5">
<span className={cn("mt-1.5 h-1.5 w-1.5 shrink-0 rounded-full", statusClass)} />
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-center gap-2">
<span className="truncate text-[13px] font-medium text-foreground">{job.name}</span>
{!job.enabled ? (
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10.5px] text-muted-foreground">
{t("thread.sessionInfo.disabled")}
</span>
) : null}
</div>
<div className="mt-1 line-clamp-2 text-[12px] leading-snug text-muted-foreground">
{job.payload.message}
</div>
<div className="mt-2 flex flex-wrap items-center gap-x-2 gap-y-1 text-[11.5px] text-muted-foreground/80">
<span>{schedule}</span>
<span aria-hidden>·</span>
<span title={nextRun.title}>{nextRun.label}</span>
</div>
</div>
</div>
</div>
);
}
function formatSchedule(job: SessionAutomationJob, t: TFunction) {
const locale = currentLocale();
if (isLocalTriggerAutomation(job)) {
return t("thread.sessionInfo.schedule.local");
}
if (job.schedule.kind === "at" && job.schedule.at_ms) {
return t("thread.sessionInfo.schedule.at", { time: fmtDateTime(job.schedule.at_ms, locale) });
}
if (job.schedule.kind === "every" && job.schedule.every_ms) {
return t("thread.sessionInfo.schedule.every", {
duration: formatDuration(job.schedule.every_ms, locale),
});
}
if (job.schedule.kind === "cron" && job.schedule.expr) {
return job.schedule.tz
? t("thread.sessionInfo.schedule.cronWithTz", {
expr: job.schedule.expr,
tz: job.schedule.tz,
})
: t("thread.sessionInfo.schedule.cron", { expr: job.schedule.expr });
}
return t("thread.sessionInfo.schedule.unknown");
}
function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) {
const locale = currentLocale();
if (!job.enabled) {
return { label: t("thread.sessionInfo.next.disabled"), title: "" };
}
if (job.state.pending) {
return { label: t("thread.sessionInfo.next.pending"), title: "" };
}
if (isLocalTriggerAutomation(job)) {
return { label: t("thread.sessionInfo.next.local"), title: "" };
}
const next = job.state.next_run_at_ms;
if (!next) {
return { label: t("thread.sessionInfo.next.none"), title: "" };
}
return {
label: t("thread.sessionInfo.next.label", { time: relativeTimeFrom(next, now, locale) }),
title: fmtDateTime(next, locale),
};
}
function isLocalTriggerAutomation(job: SessionAutomationJob): boolean {
return job.kind === "local_trigger"
|| job.payload.kind === "local_trigger"
|| job.schedule.kind === "local";
}
function relativeTimeFrom(value: number, now: number, locale: string): string {
let delta = (value - now) / 1000;
const formatter = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
for (const [step, unit] of RELATIVE_THRESHOLDS) {
if (Math.abs(delta) < step) {
return formatter.format(Math.round(delta), unit);
}
delta /= step;
}
return formatter.format(Math.round(delta), "year");
}
function formatDuration(ms: number, locale: string): string {
const units: Array<[Intl.NumberFormatOptions["unit"], number]> = [
["day", 86_400_000],
["hour", 3_600_000],
["minute", 60_000],
["second", 1000],
];
for (const [unit, size] of units) {
if (ms >= size && ms % size === 0) {
return new Intl.NumberFormat(locale, {
style: "unit",
unit,
unitDisplay: "long",
maximumFractionDigits: 0,
}).format(ms / size);
}
}
return new Intl.NumberFormat(locale, {
style: "unit",
unit: "minute",
unitDisplay: "long",
maximumFractionDigits: 1,
}).format(ms / 60_000);
}