fix: tighten cron session deletion UX

This commit is contained in:
chengyongru 2026-06-12 14:51:02 +08:00
parent c4b64a4caf
commit b232a52794
12 changed files with 225 additions and 18 deletions

View File

@ -572,7 +572,9 @@ nanobot gateway
DM the bot directly or @mention it in a channel — it should respond! DM the bot directly or @mention it in a channel — it should respond!
> [!TIP] > [!TIP]
> - `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels). > - `groupPolicy`: `"mention"` (default — respond only when @mentioned), `"open"` (respond to all channel messages), or `"allowlist"` (restrict to specific channels via `groupAllowFrom`).
> - `groupAllowFrom`: channel IDs the bot may respond in when `groupPolicy` is `"allowlist"`.
> - `groupRequireMention`: when `true` and `groupPolicy` is `"allowlist"`, the bot only replies to channels in `groupAllowFrom` **and** only when @mentioned (instead of every message). No effect for `"mention"`/`"open"`. Use this to scope the bot to approved channels while keeping mention-only behavior.
> - DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs. > - DM policy defaults to open. Set `"dm": {"enabled": false}` to disable DMs.
</details> </details>

View File

@ -72,7 +72,7 @@ class CronTool(Tool, ContextAware):
"""Set the current session context for scheduled cron job ownership.""" """Set the current session context for scheduled cron job ownership."""
raw_key = f"{ctx.channel}:{ctx.chat_id}" if ctx.channel and ctx.chat_id else "" raw_key = f"{ctx.channel}:{ctx.chat_id}" if ctx.channel and ctx.chat_id else ""
self._session_key.set( self._session_key.set(
raw_key if ctx.session_key in {None, "", UNIFIED_SESSION_KEY} else ctx.session_key raw_key if ctx.session_key == UNIFIED_SESSION_KEY else (ctx.session_key or "")
) )
def set_cron_context(self, active: bool): def set_cron_context(self, active: bool):

View File

@ -8,8 +8,11 @@ import {
AlertDialogHeader, AlertDialogHeader,
AlertDialogTitle, AlertDialogTitle,
} from "@/components/ui/alert-dialog"; } from "@/components/ui/alert-dialog";
import type { TFunction } from "i18next";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { currentLocale } from "@/i18n";
import { fmtDateTime } from "@/lib/format";
import type { SessionAutomationJob } from "@/lib/types"; import type { SessionAutomationJob } from "@/lib/types";
interface DeleteConfirmProps { interface DeleteConfirmProps {
@ -28,6 +31,7 @@ export function DeleteConfirm({
onConfirm, onConfirm,
}: DeleteConfirmProps) { }: DeleteConfirmProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const locale = currentLocale();
const hasAutomations = automations.length > 0; const hasAutomations = automations.length > 0;
const visibleAutomations = automations.slice(0, 4); const visibleAutomations = automations.slice(0, 4);
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length); const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
@ -49,23 +53,29 @@ export function DeleteConfirm({
{hasAutomations {hasAutomations
? t("deleteConfirm.automationsDescription", { ? t("deleteConfirm.automationsDescription", {
count: automations.length, count: automations.length,
defaultValue:
"This chat has scheduled automations. Deleting it will also delete them.",
}) })
: t("deleteConfirm.description")} : t("deleteConfirm.description")}
</AlertDialogDescription> </AlertDialogDescription>
{hasAutomations ? ( {hasAutomations ? (
<div className="mt-4 max-h-32 w-full overflow-y-auto rounded-2xl bg-muted/55 px-3 py-2 text-left"> <div className="mt-4 max-h-40 w-full overflow-y-auto rounded-2xl bg-muted/55 px-3 py-2 text-left">
{visibleAutomations.map((job) => ( {visibleAutomations.map((job) => (
<div key={job.id} className="truncate text-[13px] leading-6 text-foreground"> <div key={job.id} className="min-w-0 py-1.5">
{job.name || job.id} <div className="truncate text-[13px] font-medium leading-5 text-foreground">
{job.name || job.id}
</div>
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-x-2 gap-y-0.5 text-[11.5px] leading-5 text-muted-foreground">
<span className="truncate">
{formatAutomationSchedule(job, t, locale)}
</span>
<span aria-hidden>·</span>
<span className="truncate">{formatAutomationNextRun(job, t, locale)}</span>
</div>
</div> </div>
))} ))}
{hiddenCount > 0 ? ( {hiddenCount > 0 ? (
<div className="text-[13px] leading-6 text-muted-foreground"> <div className="text-[13px] leading-6 text-muted-foreground">
{t("deleteConfirm.moreAutomations", { {t("deleteConfirm.moreAutomations", {
count: hiddenCount, count: hiddenCount,
defaultValue: "+ {{count}} more",
})} })}
</div> </div>
) : null} ) : null}
@ -85,7 +95,7 @@ export function DeleteConfirm({
> >
{hasAutomations {hasAutomations
? t("deleteConfirm.confirmWithAutomations", { ? t("deleteConfirm.confirmWithAutomations", {
defaultValue: "Delete all", count: automations.length,
}) })
: t("deleteConfirm.confirm")} : t("deleteConfirm.confirm")}
</AlertDialogAction> </AlertDialogAction>
@ -94,3 +104,63 @@ export function DeleteConfirm({
</AlertDialog> </AlertDialog>
); );
} }
function formatAutomationSchedule(
job: SessionAutomationJob,
t: TFunction,
locale: string,
): string {
if (job.schedule.kind === "at" && job.schedule.at_ms) {
return t("deleteConfirm.schedule.at", { time: fmtDateTime(job.schedule.at_ms, locale) });
}
if (job.schedule.kind === "every" && job.schedule.every_ms) {
return t("deleteConfirm.schedule.every", {
duration: formatDuration(job.schedule.every_ms, locale),
});
}
if (job.schedule.kind === "cron" && job.schedule.expr) {
return job.schedule.tz
? t("deleteConfirm.schedule.cronWithTz", {
expr: job.schedule.expr,
tz: job.schedule.tz,
})
: t("deleteConfirm.schedule.cron", { expr: job.schedule.expr });
}
return t("deleteConfirm.schedule.unknown");
}
function formatAutomationNextRun(
job: SessionAutomationJob,
t: TFunction,
locale: string,
): string {
if (!job.enabled) return t("deleteConfirm.next.disabled");
const next = job.state.next_run_at_ms;
if (!next) return t("deleteConfirm.next.none");
return t("deleteConfirm.next.label", { time: fmtDateTime(next, locale) });
}
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);
}

View File

@ -551,7 +551,22 @@
"title": "Delete this chat?", "title": "Delete this chat?",
"description": "This action cannot be undone.", "description": "This action cannot be undone.",
"cancel": "Cancel", "cancel": "Cancel",
"confirm": "Delete" "confirm": "Delete",
"automationsDescription": "This chat has scheduled automations. Deleting it will also delete them.",
"moreAutomations": "+ {{count}} more",
"confirmWithAutomations": "Delete chat and automations",
"schedule": {
"at": "{{time}}",
"every": "Every {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Custom schedule"
},
"next": {
"label": "Next: {{time}}",
"disabled": "Paused",
"none": "No next run"
}
}, },
"connection": { "connection": {
"idle": "Idle", "idle": "Idle",

View File

@ -551,7 +551,22 @@
"title": "¿Eliminar este chat?", "title": "¿Eliminar este chat?",
"description": "Esta acción no se puede deshacer.", "description": "Esta acción no se puede deshacer.",
"cancel": "Cancelar", "cancel": "Cancelar",
"confirm": "Eliminar" "confirm": "Eliminar",
"automationsDescription": "Este chat tiene automatizaciones programadas. Al eliminarlo también se eliminarán.",
"moreAutomations": "+ {{count}} más",
"confirmWithAutomations": "Eliminar chat y automatizaciones",
"schedule": {
"at": "{{time}}",
"every": "Cada {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Programación personalizada"
},
"next": {
"label": "Siguiente: {{time}}",
"disabled": "Pausada",
"none": "Sin próxima ejecución"
}
}, },
"connection": { "connection": {
"idle": "Inactivo", "idle": "Inactivo",

View File

@ -551,7 +551,22 @@
"title": "Supprimer cette discussion ?", "title": "Supprimer cette discussion ?",
"description": "Cette action est irréversible.", "description": "Cette action est irréversible.",
"cancel": "Annuler", "cancel": "Annuler",
"confirm": "Supprimer" "confirm": "Supprimer",
"automationsDescription": "Cette discussion contient des automatisations planifiées. La supprimer les supprimera aussi.",
"moreAutomations": "+ {{count}} autres",
"confirmWithAutomations": "Supprimer la discussion et les automatisations",
"schedule": {
"at": "{{time}}",
"every": "Tous les {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Planification personnalisée"
},
"next": {
"label": "Prochaine exécution : {{time}}",
"disabled": "En pause",
"none": "Aucune prochaine exécution"
}
}, },
"connection": { "connection": {
"idle": "Inactif", "idle": "Inactif",

View File

@ -551,7 +551,22 @@
"title": "Hapus obrolan ini?", "title": "Hapus obrolan ini?",
"description": "Tindakan ini tidak dapat dibatalkan.", "description": "Tindakan ini tidak dapat dibatalkan.",
"cancel": "Batal", "cancel": "Batal",
"confirm": "Hapus" "confirm": "Hapus",
"automationsDescription": "Obrolan ini memiliki automasi terjadwal. Menghapusnya juga akan menghapus automasi tersebut.",
"moreAutomations": "+ {{count}} lagi",
"confirmWithAutomations": "Hapus obrolan dan automasi",
"schedule": {
"at": "{{time}}",
"every": "Setiap {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Jadwal khusus"
},
"next": {
"label": "Berikutnya: {{time}}",
"disabled": "Dijeda",
"none": "Tidak ada jadwal berikutnya"
}
}, },
"connection": { "connection": {
"idle": "Idle", "idle": "Idle",

View File

@ -551,7 +551,22 @@
"title": "このチャットを削除しますか?", "title": "このチャットを削除しますか?",
"description": "この操作は元に戻せません。", "description": "この操作は元に戻せません。",
"cancel": "キャンセル", "cancel": "キャンセル",
"confirm": "削除" "confirm": "削除",
"automationsDescription": "このチャットにはスケジュール済みの自動タスクがあります。削除するとそれらも削除されます。",
"moreAutomations": "他 {{count}} 件",
"confirmWithAutomations": "チャットと自動タスクを削除",
"schedule": {
"at": "{{time}}",
"every": "{{duration}} ごと",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "カスタムスケジュール"
},
"next": {
"label": "次回: {{time}}",
"disabled": "一時停止中",
"none": "次回実行なし"
}
}, },
"connection": { "connection": {
"idle": "待機中", "idle": "待機中",

View File

@ -551,7 +551,22 @@
"title": "이 채팅을 삭제할까요?", "title": "이 채팅을 삭제할까요?",
"description": "이 작업은 되돌릴 수 없습니다.", "description": "이 작업은 되돌릴 수 없습니다.",
"cancel": "취소", "cancel": "취소",
"confirm": "삭제" "confirm": "삭제",
"automationsDescription": "이 채팅에는 예약된 자동화가 있습니다. 채팅을 삭제하면 자동화도 함께 삭제됩니다.",
"moreAutomations": "+ {{count}}개 더",
"confirmWithAutomations": "채팅과 자동화 삭제",
"schedule": {
"at": "{{time}}",
"every": "{{duration}}마다",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "사용자 지정 일정"
},
"next": {
"label": "다음: {{time}}",
"disabled": "일시 중지됨",
"none": "다음 실행 없음"
}
}, },
"connection": { "connection": {
"idle": "대기 중", "idle": "대기 중",

View File

@ -551,7 +551,22 @@
"title": "Xóa cuộc trò chuyện này?", "title": "Xóa cuộc trò chuyện này?",
"description": "Không thể hoàn tác thao tác này.", "description": "Không thể hoàn tác thao tác này.",
"cancel": "Hủy", "cancel": "Hủy",
"confirm": "Xóa" "confirm": "Xóa",
"automationsDescription": "Cuộc trò chuyện này có các tự động hóa đã lên lịch. Xóa cuộc trò chuyện cũng sẽ xóa chúng.",
"moreAutomations": "+ {{count}} mục nữa",
"confirmWithAutomations": "Xóa trò chuyện và tự động hóa",
"schedule": {
"at": "{{time}}",
"every": "Mỗi {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "Lịch tùy chỉnh"
},
"next": {
"label": "Tiếp theo: {{time}}",
"disabled": "Đã tạm dừng",
"none": "Không có lần chạy tiếp theo"
}
}, },
"connection": { "connection": {
"idle": "Rảnh", "idle": "Rảnh",

View File

@ -551,7 +551,22 @@
"title": "删除这个对话?", "title": "删除这个对话?",
"description": "此操作无法撤销。", "description": "此操作无法撤销。",
"cancel": "取消", "cancel": "取消",
"confirm": "删除" "confirm": "删除",
"automationsDescription": "这个对话有关联的自动任务。删除对话也会删除这些自动任务。",
"moreAutomations": "另有 {{count}} 个",
"confirmWithAutomations": "删除对话和自动任务",
"schedule": {
"at": "{{time}}",
"every": "每 {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "自定义计划"
},
"next": {
"label": "下次:{{time}}",
"disabled": "已暂停",
"none": "没有下次运行"
}
}, },
"connection": { "connection": {
"idle": "空闲", "idle": "空闲",

View File

@ -551,7 +551,22 @@
"title": "刪除這個對話?", "title": "刪除這個對話?",
"description": "此操作無法復原。", "description": "此操作無法復原。",
"cancel": "取消", "cancel": "取消",
"confirm": "刪除" "confirm": "刪除",
"automationsDescription": "這個對話有關聯的自動任務。刪除對話也會刪除這些自動任務。",
"moreAutomations": "另有 {{count}} 個",
"confirmWithAutomations": "刪除對話和自動任務",
"schedule": {
"at": "{{time}}",
"every": "每 {{duration}}",
"cron": "Cron {{expr}}",
"cronWithTz": "Cron {{expr}} · {{tz}}",
"unknown": "自訂計畫"
},
"next": {
"label": "下次:{{time}}",
"disabled": "已暫停",
"none": "沒有下次執行"
}
}, },
"connection": { "connection": {
"idle": "閒置", "idle": "閒置",