From f9c331315bca4c94ce27e792654eb2f597e0c2e5 Mon Sep 17 00:00:00 2001 From: duanshuwen Date: Tue, 21 Apr 2026 21:41:18 +0800 Subject: [PATCH] feat: add CronDeleteDialog component and integrate it into CronPage for job deletion - Implemented a new CronDeleteDialog component for confirming job deletions. - Integrated the CronDeleteDialog into the CronPage, allowing users to delete cron jobs with confirmation. - Refactored job deletion logic to handle state updates and loading indicators during deletion. - Removed unused delivery channel related code from CronPage and CronTaskDialog. - Cleaned up chat store session deletion logic to improve state management and ensure proper session handling. --- src/components/chat/ChatMessageList.tsx | 2 +- src/i18n/locales/en/cron.json | 6 + src/i18n/locales/th/cron.json | 6 + src/i18n/locales/zh/cron.json | 6 + .../Cron/components/CronDeleteDialog.tsx | 117 +++ src/pages/Cron/index.tsx | 829 +----------------- src/stores/chat.ts | 42 +- 7 files changed, 188 insertions(+), 820 deletions(-) create mode 100644 src/pages/Cron/components/CronDeleteDialog.tsx diff --git a/src/components/chat/ChatMessageList.tsx b/src/components/chat/ChatMessageList.tsx index 633b600..157a3d1 100644 --- a/src/components/chat/ChatMessageList.tsx +++ b/src/components/chat/ChatMessageList.tsx @@ -21,7 +21,7 @@ function ChatMessageList({ messages, loading, showWelcomeState }: ChatMessageLis }, [messages]); return ( -
+
{loading ? (
diff --git a/src/i18n/locales/en/cron.json b/src/i18n/locales/en/cron.json index 6a59fbd..bdacdf6 100644 --- a/src/i18n/locales/en/cron.json +++ b/src/i18n/locales/en/cron.json @@ -35,6 +35,12 @@ "deleted": "Task deleted." }, "confirmDelete": "Delete “{name}”? This cannot be undone.", + "deleteDialog": { + "title": "Delete task", + "label": "Task name", + "confirm": "Confirm delete", + "deleting": "Deleting..." + }, "common": { "unnamedJob": "Untitled task" }, diff --git a/src/i18n/locales/th/cron.json b/src/i18n/locales/th/cron.json index da9d75a..ab74e24 100644 --- a/src/i18n/locales/th/cron.json +++ b/src/i18n/locales/th/cron.json @@ -35,6 +35,12 @@ "deleted": "ลบงานแล้ว" }, "confirmDelete": "ต้องการลบ \"{name}\" หรือไม่? การดำเนินการนี้ไม่สามารถย้อนกลับได้", + "deleteDialog": { + "title": "ลบงาน", + "label": "ชื่องาน", + "confirm": "ยืนยันการลบ", + "deleting": "กำลังลบ..." + }, "common": { "unnamedJob": "งานไม่มีชื่อ" }, diff --git a/src/i18n/locales/zh/cron.json b/src/i18n/locales/zh/cron.json index 872b0d1..daa19b4 100644 --- a/src/i18n/locales/zh/cron.json +++ b/src/i18n/locales/zh/cron.json @@ -35,6 +35,12 @@ "deleted": "任务已删除。" }, "confirmDelete": "确认删除“{name}”吗?删除后将无法恢复。", + "deleteDialog": { + "title": "删除任务", + "label": "任务名称", + "confirm": "确认删除", + "deleting": "删除中..." + }, "common": { "unnamedJob": "未命名任务" }, diff --git a/src/pages/Cron/components/CronDeleteDialog.tsx b/src/pages/Cron/components/CronDeleteDialog.tsx new file mode 100644 index 0000000..1a4bbf6 --- /dev/null +++ b/src/pages/Cron/components/CronDeleteDialog.tsx @@ -0,0 +1,117 @@ +import * as Dialog from '@radix-ui/react-dialog'; +import { Loader2, Trash2, X } from 'lucide-react'; +import type { CronJob } from '../../../lib/cron-types'; +import { useI18n } from '../../../i18n'; + +type CronDeleteDialogProps = { + open: boolean; + job: CronJob | null; + busy: boolean; + onClose: () => void; + onConfirm: () => void; +}; + +export default function CronDeleteDialog({ + open, + job, + busy, + onClose, + onConfirm, +}: CronDeleteDialogProps) { + const { t } = useI18n(); + const jobName = job?.name.trim() ? job.name : t('cron.common.unnamedJob'); + + function handleOpenChange(nextOpen: boolean): void { + if (!nextOpen && !busy) { + onClose(); + } + } + + return ( + + + + { + if (busy) { + event.preventDefault(); + } + }} + onPointerDownOutside={(event) => { + if (busy) { + event.preventDefault(); + } + }} + > +
+
+
+ +
+ +
+ + {t('cron.deleteDialog.title')} + + + {t('cron.confirmDelete', { name: jobName })} + +
+
+ + +
+ +
+
+
+ {t('cron.deleteDialog.label')} +
+
+ {jobName} +
+
+ +
+ + +
+
+
+
+
+ ); +} diff --git a/src/pages/Cron/index.tsx b/src/pages/Cron/index.tsx index 9b37e18..f0b92d9 100644 --- a/src/pages/Cron/index.tsx +++ b/src/pages/Cron/index.tsx @@ -4,25 +4,19 @@ import { useState, type ReactNode, type SVGProps, - type SelectHTMLAttributes, } from 'react'; -import { DEFAULT_AGENT_ID, normalizeAgentId, normalizeChannelType, type AgentSummary } from '@runtime/lib/agents'; +import { DEFAULT_AGENT_ID, normalizeAgentId, type AgentSummary } from '@runtime/lib/agents'; import { onGatewayEvent } from '../../lib/gateway-client'; import { hostApiFetch } from '../../lib/host-api'; -import type { - ChannelTargetCatalogItem, - ChannelTargetsCatalogResponse, -} from '../../lib/channel-types'; import { isRuntimeChangedGatewayEvent, runtimeEventHasTopic } from '../../lib/runtime-events'; import type { - CronDeliveryChannelAccount, - CronDeliveryChannelGroup, CronJob, CronJobCreateInput, CronJobDelivery, } from '../../lib/cron-types'; import { agentsStore, useAgentsStore } from '../../stores'; import { useI18n, type LanguageCode } from '../../i18n'; +import CronDeleteDialog from './components/CronDeleteDialog'; type FeedbackTone = 'success' | 'error' | 'info' | 'warning'; @@ -38,9 +32,6 @@ type DialogProps = { job: CronJob | null; saving: boolean; defaultAgentId: string; - channelGroups: CronDeliveryChannelGroup[]; - channelsLoading: boolean; - channelsError: string | null; onClose: () => void; onSave: (input: CronJobCreateInput) => Promise; }; @@ -49,7 +40,6 @@ type JobCardProps = { job: CronJob; agentLabel: string; agentDetail: string; - deliverySummary: string; busyAction: string | null; onToggle: (enabled: boolean) => void; onEdit: () => void; @@ -79,50 +69,6 @@ type CronJobsResponse = error?: string; }; -type ChannelAccountsResponse = - | unknown[] - | { - success?: boolean; - channels?: unknown; - groups?: unknown; - accounts?: unknown; - data?: unknown; - error?: string; - }; - -type ChannelTargetsResponse = - | unknown[] - | ChannelTargetsCatalogResponse - | { - success?: boolean; - targets?: unknown; - items?: unknown; - data?: unknown; - error?: string; - }; - -type DeliveryChannelGroupLike = Partial & { - name?: string; - label?: string; - channelName?: string; - accounts?: unknown; -}; - -type DeliveryChannelAccountLike = Partial & { - id?: string; - label?: string; - channelName?: string; - channelType?: string; - name?: string; - default?: boolean; -}; - -type DeliveryTargetOptionLike = Partial & { - name?: string; - title?: string; - desc?: string; -}; - const SCHEDULE_PRESETS: SchedulePreset[] = [ { key: 'every-minute', value: '* * * * *' }, { key: 'every-5-minutes', value: '*/5 * * * *' }, @@ -134,17 +80,6 @@ const SCHEDULE_PRESETS: SchedulePreset[] = [ { key: 'monthly-first', value: '0 9 1 * *' }, ]; -const CHANNEL_NAME_KEYS: Record = { - douyin: 'cron.channels.douyin', - feishu: 'cron.channels.feishu', - fliggy: 'cron.channels.fliggy', - meituan: 'cron.channels.meituan', - qqbot: 'cron.channels.qqbot', - telegram: 'cron.channels.telegram', - wechat: 'cron.channels.wechat', - wecom: 'cron.channels.wecom', -}; - function buildFallbackCronJobs(t: Translate): CronJob[] { return [ { @@ -369,23 +304,6 @@ function BotIcon(props: SVGProps) { ); } -function SendIcon(props: SVGProps) { - return ( - - - - - ); -} - -function ChevronDownIcon(props: SVGProps) { - return ( - - - - ); -} - function getScheduleExpression(schedule: CronJob['schedule']): string { if (typeof schedule === 'string') return schedule; if (schedule.kind === 'cron') return schedule.expr; @@ -654,317 +572,6 @@ function normalizeCronJobResult(payload: unknown): CronJob | null { return normalizeCronJob(payload); } -function normalizeDeliveryChannelAccount( - value: unknown, - fallbackDefaultAccountId?: string, -): CronDeliveryChannelAccount | null { - if (!isRecord(value)) return null; - - const account = value as DeliveryChannelAccountLike; - const accountId = getString(account.accountId) || getString(account.id) || 'default'; - const name = - getString(account.name) - || getString(account.label) - || getString(account.channelName) - || accountId; - - return { - accountId, - name, - isDefault: Boolean(account.isDefault || account.default || accountId === fallbackDefaultAccountId || accountId === 'default'), - }; -} - -function normalizeGroupedChannelEntry(value: unknown): CronDeliveryChannelGroup | null { - if (!isRecord(value)) return null; - - const group = value as DeliveryChannelGroupLike; - const channelType = normalizeChannelType(getString(group.channelType)); - if (!channelType) return null; - - const fallbackDefaultAccountId = getString(group.defaultAccountId) || 'default'; - const accounts = Array.isArray(group.accounts) - ? group.accounts - .map((account) => normalizeDeliveryChannelAccount(account, fallbackDefaultAccountId)) - .filter((account): account is CronDeliveryChannelAccount => Boolean(account)) - : []; - - const uniqueAccounts = dedupeDeliveryAccounts(accounts); - const defaultAccountId = - uniqueAccounts.find((account) => account.isDefault)?.accountId - || fallbackDefaultAccountId - || uniqueAccounts[0]?.accountId - || 'default'; - - return { - channelType, - defaultAccountId, - accounts: uniqueAccounts.map((account) => - account.accountId === defaultAccountId ? { ...account, isDefault: true } : account, - ), - }; -} - -function dedupeDeliveryAccounts(accounts: CronDeliveryChannelAccount[]): CronDeliveryChannelAccount[] { - const byId = new Map(); - for (const account of accounts) { - const existing = byId.get(account.accountId); - if (!existing) { - byId.set(account.accountId, account); - continue; - } - - byId.set(account.accountId, { - accountId: account.accountId, - name: existing.name || account.name, - isDefault: existing.isDefault || account.isDefault, - }); - } - - return Array.from(byId.values()).sort((left, right) => { - if (left.isDefault !== right.isDefault) return left.isDefault ? -1 : 1; - return left.name.localeCompare(right.name, 'zh-CN'); - }); -} - -function normalizeFlatChannelAccounts(payload: unknown[]): CronDeliveryChannelGroup[] { - const groupMap = new Map(); - - for (const entry of payload) { - if (!isRecord(entry)) continue; - - const account = entry as DeliveryChannelAccountLike; - const channelType = normalizeChannelType(getString(account.channelType)); - if (!channelType) continue; - - const normalizedAccount = normalizeDeliveryChannelAccount(entry); - if (!normalizedAccount) continue; - - const currentAccounts = groupMap.get(channelType) ?? []; - currentAccounts.push(normalizedAccount); - groupMap.set(channelType, currentAccounts); - } - - return Array.from(groupMap.entries()) - .map(([channelType, accounts]) => { - const uniqueAccounts = dedupeDeliveryAccounts(accounts); - const defaultAccountId = - uniqueAccounts.find((account) => account.isDefault)?.accountId - || uniqueAccounts[0]?.accountId - || 'default'; - - return { - channelType, - defaultAccountId, - accounts: uniqueAccounts.map((account) => - account.accountId === defaultAccountId ? { ...account, isDefault: true } : account, - ), - }; - }) - .sort((left, right) => getChannelDisplayName(left.channelType).localeCompare(getChannelDisplayName(right.channelType), 'zh-CN')); -} - -function normalizeDeliveryChannelGroups(payload: unknown): CronDeliveryChannelGroup[] { - if (Array.isArray(payload)) { - const groupedEntries = payload - .map(normalizeGroupedChannelEntry) - .filter((entry): entry is CronDeliveryChannelGroup => Boolean(entry)); - - if (groupedEntries.length > 0) { - return groupedEntries.sort((left, right) => - getChannelDisplayName(left.channelType).localeCompare(getChannelDisplayName(right.channelType), 'zh-CN'), - ); - } - - return normalizeFlatChannelAccounts(payload); - } - - if (!isRecord(payload)) return []; - - if (Array.isArray(payload.channels)) { - return normalizeDeliveryChannelGroups(payload.channels); - } - - if (Array.isArray(payload.groups)) { - return normalizeDeliveryChannelGroups(payload.groups); - } - - if (Array.isArray(payload.accounts)) { - return normalizeDeliveryChannelGroups(payload.accounts); - } - - if (Array.isArray(payload.data)) { - return normalizeDeliveryChannelGroups(payload.data); - } - - return []; -} - -function normalizeDeliveryTargetOption(value: unknown): ChannelTargetCatalogItem | null { - if (!isRecord(value)) return null; - - const option = value as DeliveryTargetOptionLike; - const targetValue = getString(option.value); - if (!targetValue) return null; - - const kind = option.kind === 'name' - || option.kind === 'identifier' - || option.kind === 'webhook' - || option.kind === 'url' - ? option.kind - : undefined; - const source = option.source === 'channel-name' - || option.source === 'account-id' - || option.source === 'remote' - || option.source === 'query-param' - || option.source === 'hash-param' - || option.source === 'channel-url' - || option.source === 'fallback' - ? option.source - : undefined; - - return { - value: targetValue, - label: getString(option.label) || getString(option.title) || getString(option.name) || targetValue, - description: getString(option.description) || getString(option.desc) || undefined, - kind, - source, - channelType: getString(option.channelType) || undefined, - accountId: getString(option.accountId) || undefined, - }; -} - -function dedupeDeliveryTargetOptions(options: ChannelTargetCatalogItem[]): ChannelTargetCatalogItem[] { - const byValue = new Map(); - - for (const option of options) { - const existing = byValue.get(option.value); - if (!existing) { - byValue.set(option.value, option); - continue; - } - - byValue.set(option.value, { - ...existing, - label: existing.label || option.label, - description: existing.description || option.description, - kind: existing.kind || option.kind, - source: existing.source || option.source, - channelType: existing.channelType || option.channelType, - accountId: existing.accountId || option.accountId, - }); - } - - const kindOrder: Record, number> = { - name: 0, - identifier: 1, - webhook: 2, - url: 3, - }; - - return Array.from(byValue.values()).sort((left, right) => { - const leftOrder = left.kind ? kindOrder[left.kind] : 99; - const rightOrder = right.kind ? kindOrder[right.kind] : 99; - if (leftOrder !== rightOrder) return leftOrder - rightOrder; - return left.label.localeCompare(right.label, 'zh-CN'); - }); -} - -function normalizeDeliveryTargetOptions(payload: unknown): ChannelTargetCatalogItem[] { - if (Array.isArray(payload)) { - return dedupeDeliveryTargetOptions( - payload - .map(normalizeDeliveryTargetOption) - .filter((option): option is ChannelTargetCatalogItem => Boolean(option)), - ); - } - - if (!isRecord(payload)) return []; - - if (Array.isArray(payload.targets)) { - return normalizeDeliveryTargetOptions(payload.targets); - } - - if (Array.isArray(payload.items)) { - return normalizeDeliveryTargetOptions(payload.items); - } - - if (Array.isArray(payload.data)) { - return normalizeDeliveryTargetOptions(payload.data); - } - - return []; -} - -function mergeDeliveryTargetOptions( - options: ChannelTargetCatalogItem[], - currentValue: string, - t: Translate, -): ChannelTargetCatalogItem[] { - const trimmedValue = currentValue.trim(); - if (!trimmedValue) { - return options; - } - - if (options.some((option) => option.value === trimmedValue)) { - return options; - } - - return dedupeDeliveryTargetOptions([ - { - value: trimmedValue, - label: trimmedValue, - description: t('cron.deliveryTarget.savedCustom'), - source: 'fallback', - }, - ...options, - ]); -} - -function ensureChannelGroupSelection( - groups: CronDeliveryChannelGroup[], - channelType: string, - accountId?: string, -): CronDeliveryChannelGroup[] { - const normalizedChannelType = normalizeChannelType(channelType); - if (!normalizedChannelType) return groups; - if (groups.some((group) => group.channelType === normalizedChannelType)) return groups; - - return [ - ...groups, - { - channelType: normalizedChannelType, - defaultAccountId: accountId || 'default', - accounts: accountId - ? [{ accountId, name: accountId, isDefault: true }] - : [], - }, - ]; -} - -function getChannelDisplayName(channelType: string, t: Translate): string { - const normalized = normalizeChannelType(channelType); - if (!normalized) return t('cron.channels.unnamed'); - - const key = CHANNEL_NAME_KEYS[normalized]; - if (key) { - const translated = t(key); - if (translated !== key) return translated; - } - - return normalized - .split(/[-_]/) - .filter(Boolean) - .map((token) => token.slice(0, 1).toUpperCase() + token.slice(1)) - .join(' '); -} - -function getDeliveryAccountDisplayName(account: CronDeliveryChannelAccount | undefined, t: Translate): string { - if (!account) return t('cron.channels.mainAccount'); - if (account.accountId === 'default' && (account.name === 'default' || !account.name.trim())) return t('cron.channels.mainAccount'); - return account.name; -} - function getAgentLabel( agentId: string | null | undefined, agentsById: Map, @@ -1011,34 +618,6 @@ function getAgentDetail( return t('cron.agent.syncPending'); } -function describeDelivery( - delivery: CronJobDelivery | undefined, - channelGroups: CronDeliveryChannelGroup[], - t: Translate, -): string { - if (!delivery || delivery.mode !== 'announce') { - return t('cron.delivery.none'); - } - - const channelType = normalizeChannelType(delivery.channel); - if (!channelType) { - return t('cron.delivery.pending'); - } - - const channelGroup = channelGroups.find((group) => group.channelType === channelType); - const accountId = delivery.accountId || channelGroup?.defaultAccountId; - const account = channelGroup?.accounts.find((item) => item.accountId === accountId) || channelGroup?.accounts[0]; - const target = getString(delivery.to); - - return [ - getChannelDisplayName(channelType, t), - account ? getDeliveryAccountDisplayName(account, t) : null, - target || t('cron.delivery.targetPending'), - ] - .filter(Boolean) - .join(' / '); -} - function buildLocalCronJob(input: CronJobCreateInput, current?: CronJob | null): CronJob { const schedule = input.schedule.trim(); const nextRun = estimateNextRunDate(schedule)?.toISOString() ?? current?.nextRun; @@ -1089,23 +668,6 @@ function Notice({ ); } -function SelectField({ className, children, ...props }: SelectHTMLAttributes & { children: ReactNode }) { - return ( -
- - -
- ); -} - function StatCard({ label, value, tone, icon: Icon }: StatCardProps) { const iconWrapperClass = tone === 'green' @@ -1135,7 +697,6 @@ function CronJobCard({ job, agentLabel, agentDetail, - deliverySummary, busyAction, onToggle, onEdit, @@ -1144,7 +705,6 @@ function CronJobCard({ }: JobCardProps) { const { t, locale } = useI18n(); const disabled = busyAction !== null; - const isAnnouncement = job.delivery?.mode === 'announce'; const jobName = job.name.trim() ? job.name : t('cron.common.unnamedJob'); return ( @@ -1167,16 +727,6 @@ function CronJobCard({ )} aria-label={job.enabled ? t('cron.card.enabled') : t('cron.card.paused')} /> - - {isAnnouncement ? t('cron.card.modeAnnounce') : t('cron.card.modeNone')} -
@@ -1224,13 +774,6 @@ function CronJobCard({ {agentDetail}
-
- - - {t('cron.card.deliveryLabel')} - {deliverySummary} - -
@@ -1296,9 +839,6 @@ function CronTaskDialog({ job, saving, defaultAgentId, - channelGroups, - channelsLoading, - channelsError, onClose, onSave, }: DialogProps) { @@ -1309,14 +849,6 @@ function CronTaskDialog({ const [enabled, setEnabled] = useState(true); const [useCustom, setUseCustom] = useState(false); const [customSchedule, setCustomSchedule] = useState(''); - const [deliveryMode, setDeliveryMode] = useState<'none' | 'announce'>('none'); - const [deliveryChannel, setDeliveryChannel] = useState(''); - const [selectedDeliveryAccountId, setSelectedDeliveryAccountId] = useState(''); - const [deliveryTarget, setDeliveryTarget] = useState(''); - const [loadedDeliveryTargetOptions, setLoadedDeliveryTargetOptions] = useState([]); - const [targetsLoading, setTargetsLoading] = useState(false); - const [targetsError, setTargetsError] = useState(null); - const [deliveryTargetScopeKey, setDeliveryTargetScopeKey] = useState(''); const [validationError, setValidationError] = useState(null); useEffect(() => { @@ -1331,162 +863,14 @@ function CronTaskDialog({ setEnabled(job?.enabled ?? true); setUseCustom(!matchedPreset); setCustomSchedule(matchedPreset ? '' : initialSchedule); - setDeliveryMode(job?.delivery?.mode === 'announce' ? 'announce' : 'none'); - setDeliveryChannel(getString(job?.delivery?.channel)); - setSelectedDeliveryAccountId(getString(job?.delivery?.accountId)); - setDeliveryTarget(getString(job?.delivery?.to)); - setLoadedDeliveryTargetOptions([]); - setTargetsLoading(false); - setTargetsError(null); - setDeliveryTargetScopeKey(''); setValidationError(null); }, [job, open]); - const availableChannelGroups = useMemo( - () => ensureChannelGroupSelection(channelGroups, deliveryChannel, selectedDeliveryAccountId), - [channelGroups, deliveryChannel, selectedDeliveryAccountId], - ); - - const selectedChannelGroup = useMemo( - () => availableChannelGroups.find((group) => group.channelType === normalizeChannelType(deliveryChannel)), - [availableChannelGroups, deliveryChannel], - ); - - const deliveryTargetOptions = useMemo( - () => mergeDeliveryTargetOptions(loadedDeliveryTargetOptions, deliveryTarget, t), - [deliveryTarget, loadedDeliveryTargetOptions, t], - ); - - const targetListId = useMemo( - () => `cron-delivery-targets-${job?.id ?? 'draft'}`, - [job?.id], - ); - - useEffect(() => { - if (!open || deliveryMode !== 'announce') return; - - if (!deliveryChannel && availableChannelGroups[0]) { - setDeliveryChannel(availableChannelGroups[0].channelType); - } - }, [availableChannelGroups, deliveryChannel, deliveryMode, open]); - - useEffect(() => { - if (!open || deliveryMode !== 'announce') return; - - const resolvedDefaultAccountId = - selectedChannelGroup?.defaultAccountId - || selectedChannelGroup?.accounts[0]?.accountId - || ''; - - if (!resolvedDefaultAccountId) { - setSelectedDeliveryAccountId(''); - return; - } - - const hasSelectedAccount = selectedChannelGroup?.accounts.some((account) => account.accountId === selectedDeliveryAccountId); - if (!selectedDeliveryAccountId || !hasSelectedAccount) { - setSelectedDeliveryAccountId(resolvedDefaultAccountId); - } - }, [deliveryMode, open, selectedChannelGroup, selectedDeliveryAccountId]); - - useEffect(() => { - if (!open || deliveryMode !== 'announce') { - setDeliveryTargetScopeKey(''); - return; - } - - const scopeKey = `${normalizeChannelType(deliveryChannel)}:${getString(selectedDeliveryAccountId || selectedChannelGroup?.defaultAccountId)}`; - if (!scopeKey || scopeKey === ':') { - setDeliveryTargetScopeKey(''); - return; - } - - setDeliveryTargetScopeKey((currentScopeKey) => { - if (currentScopeKey && currentScopeKey !== scopeKey) { - setDeliveryTarget(''); - } - return scopeKey; - }); - }, [ - deliveryChannel, - deliveryMode, - open, - selectedChannelGroup?.defaultAccountId, - selectedDeliveryAccountId, - ]); - - useEffect(() => { - if (!open || deliveryMode !== 'announce') { - setLoadedDeliveryTargetOptions([]); - setTargetsLoading(false); - setTargetsError(null); - return; - } - - const normalizedChannelType = normalizeChannelType(deliveryChannel); - const normalizedAccountId = getString(selectedDeliveryAccountId || selectedChannelGroup?.defaultAccountId); - if (!normalizedChannelType) { - setLoadedDeliveryTargetOptions([]); - setTargetsLoading(false); - setTargetsError(null); - return; - } - - let cancelled = false; - const query = new URLSearchParams({ channelType: normalizedChannelType }); - if (normalizedAccountId) { - query.set('accountId', normalizedAccountId); - } - - setTargetsLoading(true); - setTargetsError(null); - - void hostApiFetch(`/api/channels/targets?${query.toString()}`) - .then((response) => { - if (cancelled) return; - setLoadedDeliveryTargetOptions(normalizeDeliveryTargetOptions(response)); - }) - .catch((requestError) => { - if (cancelled) return; - setLoadedDeliveryTargetOptions([]); - setTargetsError(requestError instanceof Error ? requestError.message : String(requestError)); - }) - .finally(() => { - if (!cancelled) { - setTargetsLoading(false); - } - }); - - return () => { - cancelled = true; - }; - }, [ - deliveryChannel, - deliveryMode, - open, - selectedChannelGroup?.defaultAccountId, - selectedDeliveryAccountId, - ]); - if (!open) return null; const finalSchedule = useCustom ? customSchedule.trim() : schedule; const nextRunPreview = finalSchedule ? estimateNextRun(finalSchedule) : null; const resolvedAgentId = normalizeAgentId(getString(job?.agentId) || defaultAgentId || DEFAULT_AGENT_ID); - const selectedAccount = selectedChannelGroup?.accounts.find((account) => account.accountId === selectedDeliveryAccountId); - const deliveryPreview = - deliveryMode === 'announce' - ? describeDelivery( - { - mode: 'announce', - channel: deliveryChannel, - accountId: selectedDeliveryAccountId, - to: deliveryTarget, - }, - availableChannelGroups, - t, - ) - : t('cron.delivery.none'); async function handleSubmit(): Promise { if (!name.trim()) { @@ -1504,18 +888,6 @@ function CronTaskDialog({ return; } - if (deliveryMode === 'announce') { - if (!deliveryChannel) { - setValidationError(t('cron.validation.channelRequired')); - return; - } - - if (!deliveryTarget.trim()) { - setValidationError(t('cron.validation.targetRequired')); - return; - } - } - setValidationError(null); await onSave({ @@ -1524,15 +896,7 @@ function CronTaskDialog({ message: message.trim(), schedule: finalSchedule, enabled, - delivery: - deliveryMode === 'announce' - ? { - mode: 'announce', - channel: normalizeChannelType(deliveryChannel), - accountId: selectedDeliveryAccountId || selectedChannelGroup?.defaultAccountId || undefined, - to: deliveryTarget.trim(), - } - : { mode: 'none' }, + delivery: job?.delivery ?? { mode: 'none' }, }); } @@ -1642,134 +1006,6 @@ function CronTaskDialog({
-
-
{t('cron.dialog.deliveryTitle')}
-

{t('cron.dialog.deliverySubtitle')}

-
- {[ - { key: 'none', title: t('cron.dialog.deliveryOptions.noneTitle'), desc: t('cron.dialog.deliveryOptions.noneDesc') }, - { key: 'announce', title: t('cron.dialog.deliveryOptions.announceTitle'), desc: t('cron.dialog.deliveryOptions.announceDesc') }, - ].map((item) => { - const active = deliveryMode === item.key; - return ( - - ); - })} -
- - {deliveryMode === 'announce' ? ( -
-
- - setDeliveryChannel(event.target.value)} - disabled={channelsLoading || availableChannelGroups.length === 0} - > - {availableChannelGroups.length > 0 ? ( - availableChannelGroups.map((group) => ( - - )) - ) : ( - - )} - -
- -
- - setSelectedDeliveryAccountId(event.target.value)} - disabled={channelsLoading || (selectedChannelGroup?.accounts.length ?? 0) === 0} - > - {(selectedChannelGroup?.accounts.length ?? 0) > 0 ? ( - selectedChannelGroup?.accounts.map((account) => ( - - )) - ) : ( - - )} - -
- -
- - 0 ? targetListId : undefined} - value={deliveryTarget} - onChange={(event) => setDeliveryTarget(event.target.value)} - placeholder={t('cron.dialog.targetPlaceholder')} - className="h-11 w-full rounded-xl border border-transparent bg-[#EDECE4] px-4 text-[14px] text-[#171717] outline-none transition-colors focus:border-[#3B6DE8] dark:bg-[#222225] dark:text-[#f3f4f6]" - /> - {deliveryTargetOptions.length > 0 ? ( - - {deliveryTargetOptions.map((option) => ( - - ))} - - ) : null} -
- {deliveryTargetOptions.slice(0, 8).map((option) => ( - - ))} -
-
- {targetsLoading - ? t('cron.dialog.targetsLoading') - : deliveryTargetOptions.length > 0 - ? t('cron.dialog.targetsReady') - : t('cron.dialog.targetsNone')} -
-
- {t('cron.dialog.preview', { preview: deliveryPreview })} - {selectedAccount ? {t('cron.dialog.currentAccount', { account: getDeliveryAccountDisplayName(selectedAccount, t) })} : null} -
-
- - {channelsError ? : null} - {targetsError ? : null} - {!channelsError && !channelsLoading && availableChannelGroups.length === 0 ? ( - - ) : null} -
- ) : null} -
-
{t('cron.dialog.enableTitle')}
@@ -1844,16 +1080,15 @@ export default function CronPage() { const [dialogOpen, setDialogOpen] = useState(false); const [editingJob, setEditingJob] = useState(null); const [dialogSaving, setDialogSaving] = useState(false); + const [deleteTargetJob, setDeleteTargetJob] = useState(null); const [busyJobId, setBusyJobId] = useState(null); const [busyAction, setBusyAction] = useState(null); - const [channelGroups, setChannelGroups] = useState([]); - const [channelsLoading, setChannelsLoading] = useState(false); - const [channelsError, setChannelsError] = useState(null); const activeJobs = useMemo(() => jobs.filter((job) => job.enabled), [jobs]); const pausedJobs = useMemo(() => jobs.filter((job) => !job.enabled), [jobs]); const failedJobs = useMemo(() => jobs.filter((job) => job.lastRun && !job.lastRun.success), [jobs]); - const refreshing = loading || channelsLoading || agentsLoading; + const refreshing = loading || agentsLoading; + const deleteDialogBusy = busyAction === 'delete' && busyJobId === deleteTargetJob?.id; const agentsById = useMemo( () => new Map(agents.map((agent) => [normalizeAgentId(agent.id), agent])), @@ -1876,37 +1111,22 @@ export default function CronPage() { } } - async function loadDeliveryChannels(): Promise { - setChannelsLoading(true); - setChannelsError(null); - - try { - const response = await hostApiFetch('/api/channels/accounts'); - setChannelGroups(normalizeDeliveryChannelGroups(response)); - } catch (requestError) { - setChannelsError(requestError instanceof Error ? requestError.message : String(requestError)); - } finally { - setChannelsLoading(false); - } - } - useEffect(() => { void agentsStore.init(); void loadJobs(); - void loadDeliveryChannels(); }, []); useEffect(() => { return onGatewayEvent((event) => { if (!isRuntimeChangedGatewayEvent(event)) return; - if (!runtimeEventHasTopic(event, 'channels', 'providers', 'agents', 'channel-targets')) return; - void Promise.allSettled([loadDeliveryChannels(), agentsStore.load()]); + if (!runtimeEventHasTopic(event, 'agents')) return; + void agentsStore.load(); }); }, []); async function handleRefresh(): Promise { setFeedback(null); - await Promise.allSettled([loadJobs(), loadDeliveryChannels(), agentsStore.load()]); + await Promise.allSettled([loadJobs(), agentsStore.load()]); } async function handleSave(input: CronJobCreateInput): Promise { @@ -2033,11 +1253,14 @@ export default function CronPage() { } } - async function handleDelete(job: CronJob): Promise { - const jobName = job.name.trim() ? job.name : t('cron.common.unnamedJob'); - const confirmed = window.confirm(t('cron.confirmDelete', { name: jobName })); - if (!confirmed) return; + function handleRequestDelete(job: CronJob): void { + setDeleteTargetJob(job); + } + async function handleConfirmDelete(): Promise { + if (!deleteTargetJob) return; + + const job = deleteTargetJob; setBusyJobId(job.id); setBusyAction('delete'); @@ -2057,6 +1280,7 @@ export default function CronPage() { message: deleteError instanceof Error ? deleteError.message : String(deleteError), }); } finally { + setDeleteTargetJob(null); setBusyJobId(null); setBusyAction(null); } @@ -2108,7 +1332,6 @@ export default function CronPage() { {feedback ? : null} {error ? : null} {agentsError ? : null} - {channelsError ? : null} {agentsWarning ? : null} {loading && jobs.length === 0 ? ( @@ -2152,7 +1375,6 @@ export default function CronPage() { job={job} agentLabel={getAgentLabel(job.agentId, agentsById, defaultAgentId, t)} agentDetail={getAgentDetail(job.agentId, agentsById, defaultAgentId, t)} - deliverySummary={describeDelivery(job.delivery, channelGroups, t)} busyAction={busyJobId === job.id ? busyAction : null} onToggle={(enabled) => { void handleToggle(job, enabled); @@ -2162,7 +1384,7 @@ export default function CronPage() { setDialogOpen(true); }} onDelete={() => { - void handleDelete(job); + handleRequestDelete(job); }} onTrigger={() => { void handleTrigger(job); @@ -2182,9 +1404,6 @@ export default function CronPage() { job={editingJob} saving={dialogSaving} defaultAgentId={defaultAgentId} - channelGroups={channelGroups} - channelsLoading={channelsLoading} - channelsError={channelsError} onClose={() => { if (dialogSaving) return; setDialogOpen(false); @@ -2192,6 +1411,18 @@ export default function CronPage() { }} onSave={handleSave} /> + { + if (deleteDialogBusy) return; + setDeleteTargetJob(null); + }} + onConfirm={() => { + void handleConfirmDelete(); + }} + /> ); } diff --git a/src/stores/chat.ts b/src/stores/chat.ts index 24c798c..ea92eae 100644 --- a/src/stores/chat.ts +++ b/src/stores/chat.ts @@ -656,31 +656,32 @@ function selectAgent(agentId: string): void { } async function deleteSession(sessionKey: string): Promise { - if (sessionKey === state.currentSessionKey) { + const currentState = state; + const isDeletingCurrentSession = sessionKey === currentState.currentSessionKey; + + if (isDeletingCurrentSession) { resetPendingStreamingDelta(); } - try { - await gatewayRpc('session.delete', { sessionKey }); - } catch { - // keep local cleanup even if gateway delete fails - } + historyLoadInFlight.delete(sessionKey); + lastHistoryLoadAtBySession.delete(sessionKey); - const remaining = state.sessions.filter((session) => session.key !== sessionKey); + const remaining = currentState.sessions.filter((session) => session.key !== sessionKey); const basePatch: Partial = { sessions: remaining, - sessionLabels: clearSessionEntryFromMap(state.sessionLabels, sessionKey), - sessionLastActivity: clearSessionEntryFromMap(state.sessionLastActivity, sessionKey), + sessionLabels: clearSessionEntryFromMap(currentState.sessionLabels, sessionKey), + sessionLastActivity: clearSessionEntryFromMap(currentState.sessionLastActivity, sessionKey), }; - if (state.currentSessionKey === sessionKey) { - const nextSession = remaining[0]?.key ?? getDefaultMainSessionKey(); - const hasRemainingSessions = remaining.length > 0; + if (isDeletingCurrentSession) { + const nextAgentId = getAgentIdFromSessionKey(sessionKey); patchState({ ...basePatch, - currentSessionKey: nextSession, - currentAgentId: getAgentIdFromSessionKey(nextSession), + currentSessionKey: buildNewSessionKey(nextAgentId), + currentAgentId: nextAgentId, messages: [], + loading: false, + sending: false, streamingMessage: null, streamingTools: [], activeRunId: null, @@ -688,14 +689,15 @@ async function deleteSession(sessionKey: string): Promise { pendingFinal: false, lastUserMessageAt: null, }); - - if (hasRemainingSessions && nextSession) { - await loadHistory(nextSession); - } - return; + } else { + patchState(basePatch); } - patchState(basePatch); + try { + await gatewayRpc('session.delete', { sessionKey }); + } catch { + // keep local cleanup even if gateway delete fails + } } function renameSession(sessionKey: string, nextLabel: string): void {