feat(cron): bind scheduled automations to sessions
This commit is contained in:
+19
-11
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 };
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -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([
|
||||
|
||||
Reference in New Issue
Block a user