Merge PR #4299: feat(cron): bind scheduled automations to sessions

feat(cron): bind scheduled automations to sessions
This commit is contained in:
Xubin Ren
2026-06-13 00:07:55 +08:00
committed by GitHub
58 changed files with 2451 additions and 412 deletions
+38 -14
View File
@@ -36,6 +36,7 @@ import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type {
ChatSummary,
RuntimeSurface,
SessionAutomationJob,
SettingsPayload,
WorkspaceScopePayload,
WorkspacesPayload,
@@ -527,7 +528,15 @@ function Shell({
const { t, i18n } = useTranslation();
const { client, token } = useClient();
const { theme, toggle } = useTheme();
const { sessions, loading, refresh, createChat, forkChat, deleteChat } = useSessions();
const {
sessions,
loading,
refresh,
createChat,
forkChat,
deleteChat,
getSessionAutomations,
} = useSessions();
const { state: sidebarState, update: updateSidebarState } =
useSidebarState(sessions, !loading);
const initialRouteRef = useRef<ShellRoute | null>(null);
@@ -546,6 +555,7 @@ function Shell({
const [pendingDelete, setPendingDelete] = useState<{
key: string;
label: string;
automations?: SessionAutomationJob[];
} | null>(null);
const [pendingRename, setPendingRename] = useState<{
key: string;
@@ -1270,33 +1280,47 @@ function Shell({
const onConfirmDelete = useCallback(async () => {
if (!pendingDelete) return;
const key = pendingDelete.key;
const hasAutomations = (pendingDelete.automations?.length ?? 0) > 0;
const deletingActive = activeKey === key;
const currentIndex = sessions.findIndex((s) => s.key === key);
const fallbackKey = deletingActive
? (sessions[currentIndex + 1]?.key ?? sessions[currentIndex - 1]?.key ?? null)
: activeKey;
setPendingDelete(null);
if (deletingActive) {
navigate({
view: "chat",
activeKey: fallbackKey,
settingsSection: "overview",
}, { replace: true });
}
try {
await deleteChat(key);
} catch (e) {
const result = await deleteChat(
key,
hasAutomations ? { deleteAutomations: true } : undefined,
);
if (result.blocked_by_automations) {
setPendingDelete({
...pendingDelete,
automations: result.automations ?? [],
});
return;
}
setPendingDelete(null);
if (deletingActive) {
navigate({
view: "chat",
activeKey: key,
activeKey: fallbackKey,
settingsSection: "overview",
}, { replace: true });
}
} catch (e) {
console.error("Failed to delete session", e);
}
}, [pendingDelete, deleteChat, activeKey, navigate, sessions]);
const onRequestDelete = useCallback(async (key: string, label: string) => {
let automations: SessionAutomationJob[] = [];
try {
automations = await getSessionAutomations(key);
} catch {
// Delete remains protected by the backend block; prefetch only improves the first prompt.
}
setPendingDelete({ key, label, automations });
}, [getSessionAutomations]);
const headerTitle = activeSession
? sidebarState.title_overrides[activeSession.key] ||
activeSession.title ||
@@ -1333,8 +1357,7 @@ function Shell({
loading,
onNewChat,
onSelect: onSelectChat,
onRequestDelete: (key: string, label: string) =>
setPendingDelete({ key, label }),
onRequestDelete,
onTogglePin,
onRequestRename,
onToggleArchive,
@@ -1559,6 +1582,7 @@ function Shell({
<DeleteConfirm
open={!!pendingDelete}
title={pendingDelete?.label ?? ""}
automations={pendingDelete?.automations}
onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete}
/>
+102 -3
View File
@@ -8,12 +8,17 @@ import {
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import type { TFunction } from "i18next";
import { Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import { currentLocale } from "@/i18n";
import { fmtDateTime } from "@/lib/format";
import type { SessionAutomationJob } from "@/lib/types";
interface DeleteConfirmProps {
open: boolean;
title: string;
automations?: SessionAutomationJob[];
onCancel: () => void;
onConfirm: () => void;
}
@@ -21,14 +26,19 @@ interface DeleteConfirmProps {
export function DeleteConfirm({
open,
title,
automations = [],
onCancel,
onConfirm,
}: DeleteConfirmProps) {
const { t } = useTranslation();
const locale = currentLocale();
const hasAutomations = automations.length > 0;
const visibleAutomations = automations.slice(0, 4);
const hiddenCount = Math.max(0, automations.length - visibleAutomations.length);
return (
<AlertDialog open={open} onOpenChange={(o) => (!o ? onCancel() : undefined)}>
<AlertDialogContent
className="w-[min(calc(100vw-2rem),22.75rem)] gap-0 rounded-[28px] border border-white/70 bg-card/95 p-5 text-center shadow-[0_24px_80px_rgba(15,23,42,0.20)] backdrop-blur-xl data-[state=open]:zoom-in-95 sm:rounded-[28px]"
className="w-[min(calc(100vw-2rem),24rem)] gap-0 rounded-[28px] border border-white/70 bg-card/95 p-5 text-center shadow-[0_24px_80px_rgba(15,23,42,0.20)] backdrop-blur-xl data-[state=open]:zoom-in-95 sm:rounded-[28px]"
>
<AlertDialogHeader className="items-center space-y-0 text-center">
<div className="mb-5 grid h-16 w-16 place-items-center rounded-full bg-destructive/10 text-destructive">
@@ -40,8 +50,35 @@ export function DeleteConfirm({
{t("deleteConfirm.title", { title })}
</AlertDialogTitle>
<AlertDialogDescription className="mt-3 max-w-[17rem] text-center text-[14px] leading-6 text-muted-foreground">
{t("deleteConfirm.description")}
{hasAutomations
? t("deleteConfirm.automationsDescription")
: t("deleteConfirm.description")}
</AlertDialogDescription>
{hasAutomations ? (
<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) => (
<div key={job.id} className="min-w-0 py-1.5">
<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>
))}
{hiddenCount > 0 ? (
<div className="text-[13px] leading-6 text-muted-foreground">
{t("deleteConfirm.moreAutomations", {
count: hiddenCount,
})}
</div>
) : null}
</div>
) : null}
</AlertDialogHeader>
<AlertDialogFooter className="mt-7 grid grid-cols-2 gap-3 space-x-0">
<AlertDialogCancel
@@ -54,10 +91,72 @@ export function DeleteConfirm({
onClick={onConfirm}
className="h-11 rounded-full bg-destructive px-5 text-[15px] font-semibold text-destructive-foreground shadow-[0_10px_25px_rgba(239,68,68,0.28)] hover:bg-destructive/90"
>
{t("deleteConfirm.confirm")}
{hasAutomations
? t("deleteConfirm.confirmWithAutomations")
: t("deleteConfirm.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</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);
}
@@ -176,6 +176,9 @@ function formatNextRun(job: SessionAutomationJob, t: TFunction, now: number) {
if (!job.enabled) {
return { label: t("thread.sessionInfo.next.disabled"), title: "" };
}
if (job.state.pending) {
return { label: t("thread.sessionInfo.next.pending"), title: "" };
}
const next = job.state.next_run_at_ms;
if (!next) {
return { label: t("thread.sessionInfo.next.none"), title: "" };
+4 -9
View File
@@ -8,6 +8,7 @@ import {
normalizeToolProgressEvents,
toolTraceLinesFromEvents,
} from "@/lib/tool-traces";
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
import type { StreamError } from "@/lib/nanobot-client";
import type {
InboundEvent,
@@ -450,12 +451,8 @@ export function useNanobotStream(
} {
const { client } = useClient();
const [messages, setMessages] = useState<UIMessage[]>(initialMessages);
/** If the last loaded message is a trace row (e.g. "Using 2 tools"),
* the model was still processing when the page loaded — keep the
* loading spinner alive so the user sees the model is active. */
const initialStreaming = initialMessages.length > 0
? initialMessages[initialMessages.length - 1].kind === "trace"
: false;
/** If history ends in unfinished agent activity, keep the loading spinner alive. */
const initialStreaming = hasPendingAgentActivity(initialMessages);
const [isStreaming, setIsStreaming] = useState(initialStreaming || hasPendingToolCalls);
/** Unix epoch seconds when the current user turn started; cleared on ``idle``. */
const [runStartedAt, setRunStartedAt] = useState<number | null>(null);
@@ -694,9 +691,7 @@ export function useNanobotStream(
useEffect(() => {
setMessages(initialMessages);
setIsStreaming(
(initialMessages.length > 0
? initialMessages[initialMessages.length - 1].kind === "trace"
: false) || hasPendingToolCalls,
hasPendingAgentActivity(initialMessages) || hasPendingToolCalls,
);
setStreamError(null);
setRunStartedAt(chatId ? client.getRunStartedAt(chatId) : null);
+53 -11
View File
@@ -5,15 +5,24 @@ import i18n from "@/i18n";
import {
ApiError,
deleteSession as apiDeleteSession,
fetchSessionAutomations,
fetchWebuiThread,
listSessions,
} from "@/lib/api";
import { hasPendingAgentActivity } from "@/lib/activity-timeline";
import { deriveTitle } from "@/lib/format";
import type { ChatSummary, UIMessage, WorkspaceScopePayload } from "@/lib/types";
import type {
ChatSummary,
SessionAutomationJob,
SessionDeleteResult,
UIMessage,
WorkspaceScopePayload,
} from "@/lib/types";
const EMPTY_MESSAGES: UIMessage[] = [];
const INITIAL_HISTORY_PAGE_LIMIT = 160;
const OLDER_HISTORY_PAGE_LIMIT = 120;
const CHAT_CREATE_TIMEOUT_MS = 60_000;
function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
return messages.map((m, idx) => ({
@@ -23,6 +32,16 @@ function persistedMessagesToUi(messages: UIMessage[]): UIMessage[] {
}));
}
function hasPendingToolCallsFromThread(
body: Awaited<ReturnType<typeof fetchWebuiThread>>,
messages: UIMessage[],
): boolean {
if (typeof body?.has_pending_tool_calls === "boolean") {
return body.has_pending_tool_calls;
}
return hasPendingAgentActivity(messages);
}
/** Sidebar state: fetches the full session list and exposes create / delete actions. */
export function useSessions(): {
sessions: ChatSummary[];
@@ -31,7 +50,11 @@ export function useSessions(): {
refresh: () => Promise<void>;
createChat: (workspaceScope?: WorkspaceScopePayload | null) => Promise<string>;
forkChat: (sourceChatId: string, beforeUserIndex: number, title?: string) => Promise<string>;
deleteChat: (key: string) => Promise<void>;
deleteChat: (
key: string,
options?: { deleteAutomations?: boolean },
) => Promise<SessionDeleteResult>;
getSessionAutomations: (key: string) => Promise<SessionAutomationJob[]>;
} {
const { client, token } = useClient();
const [sessions, setSessions] = useState<ChatSummary[]>([]);
@@ -78,7 +101,7 @@ export function useSessions(): {
}, [client, refresh]);
const createChat = useCallback(async (workspaceScope?: WorkspaceScopePayload | null): Promise<string> => {
const chatId = await client.newChat(5_000, workspaceScope);
const chatId = await client.newChat(CHAT_CREATE_TIMEOUT_MS, workspaceScope);
const key = `websocket:${chatId}`;
optimisticKeysRef.current.add(key);
// Optimistic insert; a subsequent refresh will replace it with the
@@ -104,7 +127,12 @@ export function useSessions(): {
beforeUserIndex: number,
title?: string,
): Promise<string> => {
const chatId = await client.forkChat(sourceChatId, beforeUserIndex, title);
const chatId = await client.forkChat(
sourceChatId,
beforeUserIndex,
title,
CHAT_CREATE_TIMEOUT_MS,
);
const key = `websocket:${chatId}`;
optimisticKeysRef.current.add(key);
setSessions((prev) => [
@@ -124,15 +152,31 @@ export function useSessions(): {
}, [client]);
const deleteChat = useCallback(
async (key: string) => {
await apiDeleteSession(tokenRef.current, key);
async (key: string, options?: { deleteAutomations?: boolean }) => {
const result = await apiDeleteSession(tokenRef.current, key, options);
if (!result.deleted) return result;
optimisticKeysRef.current.delete(key);
setSessions((prev) => prev.filter((s) => s.key !== key));
return result;
},
[],
);
return { sessions, loading, error, refresh, createChat, forkChat, deleteChat };
const getSessionAutomations = useCallback(async (key: string) => {
const result = await fetchSessionAutomations(tokenRef.current, key);
return result.jobs;
}, []);
return {
sessions,
loading,
error,
refresh,
createChat,
forkChat,
deleteChat,
getSessionAutomations,
};
}
/** Lazy-load a session's on-disk messages the first time the UI displays it. */
@@ -241,8 +285,7 @@ export function useSessionHistory(key: string | null): {
return;
}
const ui = persistedMessagesToUi(body.messages);
const last = ui[ui.length - 1];
const hasPending = last?.kind === "trace";
const hasPending = hasPendingToolCallsFromThread(body, ui);
const forkBoundary = typeof body.fork_boundary_message_count === "number"
? Math.max(0, Math.min(body.fork_boundary_message_count, ui.length))
: null;
@@ -326,13 +369,12 @@ export function useSessionHistory(key: string | null): {
? null
: prev.forkBoundaryMessageCount + older.length;
const nextMessages = [...older, ...prev.messages];
const last = nextMessages[nextMessages.length - 1];
return {
...prev,
messages: nextMessages,
loadingOlder: false,
error: null,
hasPendingToolCalls: last?.kind === "trace",
hasPendingToolCalls: hasPendingAgentActivity(nextMessages),
forkBoundaryMessageCount: olderBoundary ?? shiftedBoundary,
beforeCursor: body.page?.before_cursor ?? null,
hasMoreBefore: body.page?.has_more_before === true,
+17 -1
View File
@@ -551,7 +551,22 @@
"title": "Delete this chat?",
"description": "This action cannot be undone.",
"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": {
"idle": "Idle",
@@ -648,6 +663,7 @@
},
"next": {
"label": "{{time}}",
"pending": "Runs shortly",
"disabled": "Paused",
"none": "No next run"
}
+17 -1
View File
@@ -551,7 +551,22 @@
"title": "¿Eliminar este chat?",
"description": "Esta acción no se puede deshacer.",
"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": {
"idle": "Inactivo",
@@ -648,6 +663,7 @@
},
"next": {
"label": "Siguiente {{time}}",
"pending": "Se ejecutará pronto",
"disabled": "En pausa",
"none": "Sin próxima ejecución"
}
+17 -1
View File
@@ -551,7 +551,22 @@
"title": "Supprimer cette discussion ?",
"description": "Cette action est irréversible.",
"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": {
"idle": "Inactif",
@@ -648,6 +663,7 @@
},
"next": {
"label": "Prochaine {{time}}",
"pending": "Exécution imminente",
"disabled": "En pause",
"none": "Aucune prochaine exécution"
}
+17 -1
View File
@@ -551,7 +551,22 @@
"title": "Hapus obrolan ini?",
"description": "Tindakan ini tidak dapat dibatalkan.",
"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": {
"idle": "Idle",
@@ -648,6 +663,7 @@
},
"next": {
"label": "Berikutnya {{time}}",
"pending": "Segera berjalan",
"disabled": "Dijeda",
"none": "Tidak ada jadwal berikutnya"
}
+17 -1
View File
@@ -551,7 +551,22 @@
"title": "このチャットを削除しますか?",
"description": "この操作は元に戻せません。",
"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": {
"idle": "待機中",
@@ -648,6 +663,7 @@
},
"next": {
"label": "次回 {{time}}",
"pending": "まもなく実行",
"disabled": "一時停止",
"none": "次回実行なし"
}
+17 -1
View File
@@ -551,7 +551,22 @@
"title": "이 채팅을 삭제할까요?",
"description": "이 작업은 되돌릴 수 없습니다.",
"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": {
"idle": "대기 중",
@@ -648,6 +663,7 @@
},
"next": {
"label": "다음 {{time}}",
"pending": "곧 실행됨",
"disabled": "일시 중지됨",
"none": "다음 실행 없음"
}
+17 -1
View File
@@ -551,7 +551,22 @@
"title": "Xóa cuộc trò chuyện này?",
"description": "Không thể hoàn tác thao tác nà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": {
"idle": "Rảnh",
@@ -648,6 +663,7 @@
},
"next": {
"label": "Tiếp theo {{time}}",
"pending": "Sắp chạy",
"disabled": "Đã tạm dừng",
"none": "Không có lần chạy tiếp theo"
}
+17 -1
View File
@@ -551,7 +551,22 @@
"title": "删除这个对话?",
"description": "此操作无法撤销。",
"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": {
"idle": "空闲",
@@ -648,6 +663,7 @@
},
"next": {
"label": "下次 {{time}}",
"pending": "即将执行",
"disabled": "已暂停",
"none": "没有下次运行"
}
+17 -1
View File
@@ -551,7 +551,22 @@
"title": "刪除這個對話?",
"description": "此操作無法復原。",
"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": {
"idle": "閒置",
@@ -648,6 +663,7 @@
},
"next": {
"label": "下次 {{time}}",
"pending": "即將執行",
"disabled": "已暫停",
"none": "沒有下次執行"
}
+32
View File
@@ -52,6 +52,38 @@ export function isAgentActivityMember(message: UIMessage): boolean {
return isReasoningOnlyAssistant(message) || message.kind === "trace";
}
export function hasPendingAgentActivity(messages: UIMessage[]): boolean {
if (messages.length === 0) return false;
const last = messages[messages.length - 1];
if (!isAgentActivityMember(last)) return false;
let trailingStart = messages.length - 1;
while (
trailingStart > 0
&& isAgentActivityMember(messages[trailingStart - 1])
) {
trailingStart -= 1;
}
const trailing = messages.slice(trailingStart);
if (trailing.some((message) => message.isStreaming || message.reasoningStreaming)) {
return true;
}
const previous = messages[trailingStart - 1];
if (!previous || previous.role !== "assistant" || isAgentActivityMember(previous)) {
return true;
}
const trailingTurnIds = new Set(
trailing
.map((message) => message.turnId)
.filter((turnId): turnId is string => typeof turnId === "string" && turnId.length > 0),
);
if (!previous.turnId) return trailingTurnIds.size > 0;
return trailingTurnIds.size > 0 && !trailingTurnIds.has(previous.turnId);
}
export function normalizeActivityTimeline(
messages: UIMessage[],
options: NormalizeActivityTimelineOptions = {},
+10 -4
View File
@@ -9,6 +9,7 @@ import type {
NetworkSafetySettingsUpdate,
ProviderModelsPayload,
ProviderSettingsUpdate,
SessionDeleteResult,
SessionAutomationsPayload,
SettingsPayload,
SettingsUpdate,
@@ -211,13 +212,18 @@ export async function fetchSkillDetail(
export async function deleteSession(
token: string,
key: string,
optionsOrBase?: { deleteAutomations?: boolean } | string,
base: string = "",
): Promise<boolean> {
const body = await request<{ deleted: boolean }>(
`${base}/api/sessions/${encodeURIComponent(key)}/delete`,
): Promise<SessionDeleteResult> {
const options = typeof optionsOrBase === "string" ? undefined : optionsOrBase;
const resolvedBase = typeof optionsOrBase === "string" ? optionsOrBase : base;
const query = new URLSearchParams();
if (options?.deleteAutomations) query.set("delete_automations", "true");
const suffix = query.toString() ? `?${query}` : "";
return request<SessionDeleteResult>(
`${resolvedBase}/api/sessions/${encodeURIComponent(key)}/delete${suffix}`,
token,
);
return body.deleted;
}
export async function fetchSettings(
+8
View File
@@ -113,11 +113,18 @@ export interface SessionAutomationJob {
state: {
next_run_at_ms?: number | null;
last_status?: "ok" | "error" | "skipped" | string | null;
pending?: boolean;
};
}
export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; }
export interface SessionDeleteResult {
deleted: boolean;
blocked_by_automations?: boolean;
automations?: SessionAutomationJob[];
}
export interface SkillSummary {
name: string;
description: string;
@@ -875,6 +882,7 @@ export interface WebuiThreadPersistedPayload {
savedAt?: string;
messages: UIMessage[];
fork_boundary_message_count?: number;
has_pending_tool_calls?: boolean;
page?: WebuiThreadPagePayload;
workspace_scope?: WorkspaceScopePayload;
}
+11
View File
@@ -131,6 +131,17 @@ describe("webui API helpers", () => {
);
});
it("passes the automation cascade flag when deleting a session", async () => {
await deleteSession("tok", "websocket:chat-1", { deleteAutomations: true });
expect(fetch).toHaveBeenCalledWith(
"/api/sessions/websocket%3Achat-1/delete?delete_automations=true",
expect.objectContaining({
headers: { Authorization: "Bearer tok" },
}),
);
});
it("serializes settings updates as a narrow query string", async () => {
await updateSettings("tok", {
modelPreset: "default",
+79 -4
View File
@@ -1,12 +1,14 @@
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ChatSummary } from "@/lib/types";
import i18n from "@/i18n";
import type { ChatSummary, SessionAutomationJob } from "@/lib/types";
const connectSpy = vi.fn();
const refreshSpy = vi.fn();
const createChatSpy = vi.fn().mockResolvedValue("chat-1");
const deleteChatSpy = vi.fn();
const getSessionAutomationsSpy = vi.fn<(key: string) => Promise<SessionAutomationJob[]>>();
const toggleThemeSpy = vi.fn();
const updateUrlSpy = vi.fn();
const attachSpy = vi.fn();
@@ -146,9 +148,12 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
refresh: refreshSpy,
createChat: createChatSpy,
forkChat: async () => "fork-chat",
deleteChat: async (key: string) => {
await deleteChatSpy(key);
getSessionAutomations: getSessionAutomationsSpy,
deleteChat: async (key: string, options?: { deleteAutomations?: boolean }) => {
if (options === undefined) await deleteChatSpy(key);
else await deleteChatSpy(key, options);
setSessions((prev: ChatSummary[]) => prev.filter((s) => s.key !== key));
return { deleted: true };
},
};
},
@@ -210,13 +215,15 @@ import { deriveWsUrl, fetchBootstrap } from "@/lib/bootstrap";
import App from "@/App";
describe("App layout", () => {
beforeEach(() => {
beforeEach(async () => {
await i18n.changeLanguage("en");
mockSessions = [];
connectSpy.mockClear();
updateUrlSpy.mockClear();
refreshSpy.mockReset();
createChatSpy.mockClear();
deleteChatSpy.mockReset();
getSessionAutomationsSpy.mockReset().mockResolvedValue([]);
toggleThemeSpy.mockReset();
attachSpy.mockReset();
runStatusHandlers.clear();
@@ -433,6 +440,74 @@ describe("App layout", () => {
expect(document.body.style.pointerEvents).not.toBe("none");
}, 15_000);
it("shows localized bound automations in the first delete confirmation", async () => {
mockSessions = [
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "First chat",
},
{
key: "websocket:chat-b",
channel: "websocket",
chatId: "chat-b",
createdAt: "2026-04-16T11:00:00Z",
updatedAt: "2026-04-16T11:00:00Z",
preview: "Second chat",
},
];
getSessionAutomationsSpy.mockResolvedValue([
{
id: "job-1",
name: "Daily repo check",
enabled: true,
schedule: { kind: "every", every_ms: 86_400_000 },
payload: { message: "Check the repo" },
state: { next_run_at_ms: Date.UTC(2026, 3, 17, 10, 0, 0) },
},
]);
await i18n.changeLanguage("zh-CN");
render(<App />);
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
const sidebar = screen.getByRole("navigation", { name: "侧边栏导航" });
await waitFor(() =>
expect(
within(sidebar).getByRole("button", { name: /^First chat$/ }),
).toBeInTheDocument(),
);
fireEvent.pointerDown(screen.getByLabelText(/First chat.*会话操作/), {
button: 0,
});
fireEvent.click(await screen.findByRole("menuitem", { name: "删除" }));
await waitFor(() =>
expect(screen.getByText("Daily repo check")).toBeInTheDocument(),
);
expect(getSessionAutomationsSpy).toHaveBeenCalledWith("websocket:chat-a");
expect(
screen.getByText("这个对话有关联的自动任务。删除对话也会删除这些自动任务。"),
).toBeInTheDocument();
expect(
screen.queryByText("This chat has scheduled automations. Deleting it will also delete them."),
).not.toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "删除对话和自动任务" }));
await waitFor(() =>
expect(deleteChatSpy).toHaveBeenCalledWith("websocket:chat-a", {
deleteAutomations: true,
}),
);
expect(deleteChatSpy).toHaveBeenCalledTimes(1);
expect(screen.queryByText("Daily repo check")).not.toBeInTheDocument();
}, 15_000);
it("keeps the mobile session action menu inside the sidebar sheet", async () => {
mockSessions = [
{
+30 -3
View File
@@ -5,14 +5,17 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { SessionInfoPopover } from "@/components/thread/SessionInfoPopover";
import { setAppLanguage } from "@/i18n";
function automationJob(nextRunAt = Date.now() + 3_600_000) {
function automationJob(
nextRunAt = Date.now() + 3_600_000,
state: Record<string, unknown> = {},
) {
return {
id: "job-1",
name: "Morning check",
enabled: true,
schedule: { kind: "every", every_ms: 3_600_000 },
payload: { message: "Check the project status" },
state: { next_run_at_ms: nextRunAt },
state: { next_run_at_ms: nextRunAt, ...state },
};
}
@@ -27,7 +30,8 @@ function automationsResponse(jobs: unknown[]) {
}
describe("SessionInfoPopover", () => {
beforeEach(() => {
beforeEach(async () => {
await setAppLanguage("en");
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(automationsResponse([automationJob()])),
@@ -86,6 +90,29 @@ describe("SessionInfoPopover", () => {
expect(screen.queryByText("Automations")).not.toBeInTheDocument();
});
it("shows a short pending label for deferred automations", async () => {
vi.stubGlobal(
"fetch",
vi.fn().mockResolvedValue(
automationsResponse([automationJob(Date.now() - 1000, { pending: true })]),
),
);
const user = userEvent.setup();
render(
<SessionInfoPopover
sessionKey="websocket:chat-1"
token="tok"
title="Release work"
/>,
);
await user.click(screen.getByRole("button", { name: "Session details" }));
expect(await screen.findByText("Runs shortly")).toBeInTheDocument();
expect(screen.queryByText(/ago/i)).not.toBeInTheDocument();
});
it("refreshes while open so completed one-shot automations disappear", async () => {
vi.stubGlobal(
"fetch",
+30
View File
@@ -180,6 +180,36 @@ describe("useNanobotStream", () => {
});
});
it("does not start streaming from completed trailing activity after an answer", () => {
const fake = fakeClient();
const initialMessages = [
{
id: "a1",
role: "assistant" as const,
content: "Cron test",
turnId: "cron:run",
createdAt: Date.now(),
},
{
id: "t1",
role: "tool" as const,
kind: "trace" as const,
content: "message({})",
traces: ["message({})"],
turnId: "cron:run",
createdAt: Date.now(),
},
];
const { result } = renderHook(
() => useNanobotStream("chat-cron-done", initialMessages),
{ wrapper: wrap(fake.client) },
);
expect(result.current.messages.at(-1)?.kind).toBe("trace");
expect(result.current.isStreaming).toBe(false);
});
it("drops pending stream work when switching chats", async () => {
const fake = fakeClient();
const { result, rerender } = renderHook(
+70 -4
View File
@@ -103,7 +103,7 @@ describe("useSessions", () => {
preview: "Beta",
},
]);
vi.mocked(api.deleteSession).mockResolvedValue(true);
vi.mocked(api.deleteSession).mockResolvedValue({ deleted: true });
const { result } = renderHook(() => useSessions(), {
wrapper: wrap(fakeClient()),
@@ -115,10 +115,42 @@ describe("useSessions", () => {
await result.current.deleteChat("websocket:chat-a");
});
expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a");
expect(api.deleteSession).toHaveBeenCalledWith("tok", "websocket:chat-a", undefined);
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-b"]);
});
it("keeps a session when delete is blocked by bound automations", async () => {
vi.mocked(api.listSessions).mockResolvedValue([
{
key: "websocket:chat-a",
channel: "websocket",
chatId: "chat-a",
createdAt: "2026-04-16T10:00:00Z",
updatedAt: "2026-04-16T10:00:00Z",
preview: "Alpha",
},
]);
vi.mocked(api.deleteSession).mockResolvedValue({
deleted: false,
blocked_by_automations: true,
automations: [],
});
const { result } = renderHook(() => useSessions(), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.sessions).toHaveLength(1));
let deleteResult: Awaited<ReturnType<typeof result.current.deleteChat>> | undefined;
await act(async () => {
deleteResult = await result.current.deleteChat("websocket:chat-a");
});
expect(deleteResult?.blocked_by_automations).toBe(true);
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-a"]);
});
it("refreshes sessions when the websocket reports a session update", async () => {
vi.mocked(api.listSessions)
.mockResolvedValueOnce([
@@ -187,7 +219,7 @@ describe("useSessions", () => {
await result.current.createChat();
});
expect(client.newChat).toHaveBeenCalledWith(5000, undefined);
expect(client.newChat).toHaveBeenCalledWith(60_000, undefined);
expect(result.current.sessions.map((s) => s.key)).toEqual(["websocket:chat-new"]);
await act(async () => {
@@ -226,7 +258,7 @@ describe("useSessions", () => {
await result.current.createChat(workspaceScope);
});
expect(client.newChat).toHaveBeenCalledWith(5000, workspaceScope);
expect(client.newChat).toHaveBeenCalledWith(60_000, workspaceScope);
expect(result.current.sessions[0]?.workspaceScope).toEqual(workspaceScope);
});
@@ -384,6 +416,40 @@ describe("useSessions", () => {
expect(result.current.hasPendingToolCalls).toBe(true);
});
it("uses the server pending flag for completed tails that still end with trace rows", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3,
has_pending_tool_calls: false,
messages: [
{
id: "a1",
role: "assistant",
content: "Cron test",
turnId: "cron:run",
createdAt: 1,
},
{
id: "t1",
role: "tool",
kind: "trace",
content: "message({})",
traces: ["message({})"],
turnId: "cron:run",
createdAt: 2,
},
],
});
const { result } = renderHook(() => useSessionHistory("websocket:chat-cron-done"), {
wrapper: wrap(fakeClient()),
});
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.messages.at(-1)?.kind).toBe("trace");
expect(result.current.hasPendingToolCalls).toBe(false);
});
it("does not flag transcript as pending when last row is not a trace", async () => {
vi.mocked(api.fetchWebuiThread).mockResolvedValue({
schemaVersion: 3,