feat(cron): bind scheduled automations to sessions

This commit is contained in:
chengyongru
2026-06-11 19:48:07 +08:00
parent ffae1dca6d
commit a326ba40f4
28 changed files with 1277 additions and 82 deletions
+19 -11
View File
@@ -36,6 +36,7 @@ import { ClientProvider, useClient } from "@/providers/ClientProvider";
import type {
ChatSummary,
RuntimeSurface,
SessionAutomationJob,
SettingsPayload,
WorkspaceScopePayload,
WorkspacesPayload,
@@ -546,6 +547,8 @@ function Shell({
const [pendingDelete, setPendingDelete] = useState<{
key: string;
label: string;
automations?: SessionAutomationJob[];
confirmAutomations?: boolean;
} | null>(null);
const [pendingRename, setPendingRename] = useState<{
key: string;
@@ -1275,24 +1278,28 @@ function Shell({
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,
pendingDelete.confirmAutomations ? { deleteAutomations: true } : undefined,
);
if (result.blocked_by_automations) {
setPendingDelete({
...pendingDelete,
automations: result.automations ?? [],
confirmAutomations: true,
});
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]);
@@ -1559,6 +1566,7 @@ function Shell({
<DeleteConfirm
open={!!pendingDelete}
title={pendingDelete?.label ?? ""}
automations={pendingDelete?.confirmAutomations ? pendingDelete.automations : undefined}
onCancel={() => setPendingDelete(null)}
onConfirm={onConfirmDelete}
/>
+36 -3
View File
@@ -10,10 +10,12 @@ import {
} from "@/components/ui/alert-dialog";
import { Trash2 } from "lucide-react";
import { useTranslation } from "react-i18next";
import type { SessionAutomationJob } from "@/lib/types";
interface DeleteConfirmProps {
open: boolean;
title: string;
automations?: SessionAutomationJob[];
onCancel: () => void;
onConfirm: () => void;
}
@@ -21,14 +23,18 @@ interface DeleteConfirmProps {
export function DeleteConfirm({
open,
title,
automations = [],
onCancel,
onConfirm,
}: DeleteConfirmProps) {
const { t } = useTranslation();
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 +46,31 @@ 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", {
count: automations.length,
defaultValue:
"This chat has scheduled automations. Deleting it will also delete them.",
})
: t("deleteConfirm.description")}
</AlertDialogDescription>
{hasAutomations ? (
<div className="mt-4 max-h-32 w-full overflow-y-auto rounded-2xl bg-muted/55 px-3 py-2 text-left">
{visibleAutomations.map((job) => (
<div key={job.id} className="truncate text-[13px] leading-6 text-foreground">
{job.name || job.id}
</div>
))}
{hiddenCount > 0 ? (
<div className="text-[13px] leading-6 text-muted-foreground">
{t("deleteConfirm.moreAutomations", {
count: hiddenCount,
defaultValue: "+ {{count}} more",
})}
</div>
) : null}
</div>
) : null}
</AlertDialogHeader>
<AlertDialogFooter className="mt-7 grid grid-cols-2 gap-3 space-x-0">
<AlertDialogCancel
@@ -54,7 +83,11 @@ 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", {
defaultValue: "Delete all",
})
: t("deleteConfirm.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
+14 -4
View File
@@ -9,7 +9,12 @@ import {
listSessions,
} from "@/lib/api";
import { deriveTitle } from "@/lib/format";
import type { ChatSummary, UIMessage, WorkspaceScopePayload } from "@/lib/types";
import type {
ChatSummary,
SessionDeleteResult,
UIMessage,
WorkspaceScopePayload,
} from "@/lib/types";
const EMPTY_MESSAGES: UIMessage[] = [];
const INITIAL_HISTORY_PAGE_LIMIT = 160;
@@ -31,7 +36,10 @@ 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>;
} {
const { client, token } = useClient();
const [sessions, setSessions] = useState<ChatSummary[]>([]);
@@ -124,10 +132,12 @@ 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;
},
[],
);
+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(
+6
View File
@@ -118,6 +118,12 @@ export interface SessionAutomationJob {
export interface SessionAutomationsPayload { jobs: SessionAutomationJob[]; }
export interface SessionDeleteResult {
deleted: boolean;
blocked_by_automations?: boolean;
automations?: SessionAutomationJob[];
}
export interface SkillSummary {
name: string;
description: string;
+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",
+1
View File
@@ -149,6 +149,7 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => {
deleteChat: async (key: string) => {
await deleteChatSpy(key);
setSessions((prev: ChatSummary[]) => prev.filter((s) => s.key !== key));
return { deleted: true };
},
};
},
+34 -2
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([