diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 55f94fb7c..9746e73f6 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -43,7 +43,7 @@ import type { } from "@/lib/types"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; -import { fetchSettings, fetchWorkspaces } from "@/lib/api"; +import { fetchSessionAutomations, fetchSettings, fetchWorkspaces } from "@/lib/api"; import { createRuntimeHost, getHostApi, @@ -548,7 +548,6 @@ function Shell({ key: string; label: string; automations?: SessionAutomationJob[]; - confirmAutomations?: boolean; } | null>(null); const [pendingRename, setPendingRename] = useState<{ key: string; @@ -1273,6 +1272,7 @@ 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 @@ -1281,13 +1281,12 @@ function Shell({ try { const result = await deleteChat( key, - pendingDelete.confirmAutomations ? { deleteAutomations: true } : undefined, + hasAutomations ? { deleteAutomations: true } : undefined, ); if (result.blocked_by_automations) { setPendingDelete({ ...pendingDelete, automations: result.automations ?? [], - confirmAutomations: true, }); return; } @@ -1304,6 +1303,16 @@ function Shell({ } }, [pendingDelete, deleteChat, activeKey, navigate, sessions]); + const onRequestDelete = useCallback(async (key: string, label: string) => { + let automations: SessionAutomationJob[] = []; + try { + automations = (await fetchSessionAutomations(token, key)).jobs; + } catch { + // Delete remains protected by the backend block; prefetch only improves the first prompt. + } + setPendingDelete({ key, label, automations }); + }, [token]); + const headerTitle = activeSession ? sidebarState.title_overrides[activeSession.key] || activeSession.title || @@ -1340,8 +1349,7 @@ function Shell({ loading, onNewChat, onSelect: onSelectChat, - onRequestDelete: (key: string, label: string) => - setPendingDelete({ key, label }), + onRequestDelete, onTogglePin, onRequestRename, onToggleArchive, @@ -1566,7 +1574,7 @@ function Shell({ setPendingDelete(null)} onConfirm={onConfirmDelete} /> diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 8735fa00b..9e39688ba 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -146,8 +146,9 @@ vi.mock("@/hooks/useSessions", async (importOriginal) => { refresh: refreshSpy, createChat: createChatSpy, forkChat: async () => "fork-chat", - deleteChat: async (key: string) => { - await deleteChatSpy(key); + 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 }; }, @@ -434,6 +435,73 @@ describe("App layout", () => { expect(document.body.style.pointerEvents).not.toBe("none"); }, 15_000); + it("shows 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", + }, + ]; + mockFetchRoutes({ + "/api/sessions/websocket%3Achat-a/automations": { + jobs: [ + { + 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) }, + }, + ], + }, + }); + + render(); + + await waitFor(() => expect(connectSpy).toHaveBeenCalled()); + const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" }); + await waitFor(() => + expect( + within(sidebar).getByRole("button", { name: /^First chat$/ }), + ).toBeInTheDocument(), + ); + + fireEvent.pointerDown(screen.getByLabelText("Chat actions for First chat"), { + button: 0, + }); + fireEvent.click(await screen.findByRole("menuitem", { name: "Delete" })); + + await waitFor(() => + expect(screen.getByText("Daily repo check")).toBeInTheDocument(), + ); + expect( + screen.getByText("This chat has scheduled automations. Deleting it will also delete them."), + ).toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: "Delete chat and automations" })); + + 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 = [ {