From 1986b1c2a7ec77e4fa6011209ac9dfe23fdc5344 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 13 Oct 2026 22:57:18 +0800 Subject: [PATCH 1/5] feat(notifications): replace "Mark all read" with "Clear all" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bell's header action now deletes the user's notification history instead of only marking it read. Opening the bell still marks everything read, so the badge behaves as before. - condor/notifications.py: add clear(user_id), scoped to the caller's bucket and taken under the write lock so a concurrent record() is not lost or resurrected - DELETE /api/v1/notifications route and api.clearNotifications() - NotificationBell: "Clear all" with an inline two-step confirm (Clear all? · Clear · Cancel), reset when the dropdown closes - test: clearing empties only the caller's bucket --- condor/notifications.py | 21 +++++++ condor/web/routes/notifications.py | 7 +++ .../components/layout/NotificationBell.tsx | 61 +++++++++++++++---- frontend/src/lib/api.ts | 4 ++ tests/test_notifications.py | 14 +++++ 5 files changed, 94 insertions(+), 13 deletions(-) diff --git a/condor/notifications.py b/condor/notifications.py index 012f65b38..bd8cbc87c 100644 --- a/condor/notifications.py +++ b/condor/notifications.py @@ -222,6 +222,27 @@ def mark_read(user_id: int, ids: list[str] | None = None) -> int: return sum(1 for raw in items if isinstance(raw, dict) and not raw.get("read")) +async def clear(user_id: int) -> None: + """Delete every notification in ``user_id``'s bucket. + + Takes the write lock so a notice recorded mid-clear is not resurrected (or + lost) by the two writes racing. Scoped like :func:`mark_read`: other users' + buckets are left untouched. + """ + if not user_id: + return + key = str(int(user_id)) + async with _write_lock: + data = _read_all() + if key not in data: + return + del data[key] + try: + _write_all(data) + except Exception: # noqa: BLE001 + log.warning("Could not clear notifications for %s", user_id, exc_info=True) + + def user_for_chat(chat_id: Any) -> int | None: """The dashboard user behind a Telegram chat id, or None if there is none. diff --git a/condor/web/routes/notifications.py b/condor/web/routes/notifications.py index 61e5d7ee6..d40d1a934 100644 --- a/condor/web/routes/notifications.py +++ b/condor/web/routes/notifications.py @@ -46,3 +46,10 @@ async def mark_notifications_read( ): """Mark some (or all) of this user's notifications read.""" return {"unread": notifications.mark_read(user.id, body.ids)} + + +@router.delete("") +async def clear_notifications(user: WebUser = Depends(get_current_user)): + """Delete all of this user's notifications.""" + await notifications.clear(user.id) + return {"unread": 0} diff --git a/frontend/src/components/layout/NotificationBell.tsx b/frontend/src/components/layout/NotificationBell.tsx index 13296a393..303af1366 100644 --- a/frontend/src/components/layout/NotificationBell.tsx +++ b/frontend/src/components/layout/NotificationBell.tsx @@ -1,6 +1,6 @@ import { useCallback, useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Bell, Bot, CheckCheck, Terminal, Workflow, Zap } from "lucide-react"; +import { Bell, Bot, Loader2, Terminal, Trash2, Workflow, Zap } from "lucide-react"; import { useNavigate } from "react-router-dom"; import { AnchoredMenu } from "@/components/ui/AnchoredMenu"; @@ -48,7 +48,12 @@ export function NotificationBell() { const { token } = useAuth(); const [open, setOpen] = useState(false); const [anchor, setAnchor] = useState(null); - const close = useCallback(() => setOpen(false), []); + // Clearing deletes the history for good, so it takes a second click. + const [confirmingClear, setConfirmingClear] = useState(false); + const close = useCallback(() => { + setOpen(false); + setConfirmingClear(false); + }, []); const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -72,7 +77,7 @@ export function NotificationBell() { // The dock icon carries the same number as the bell (FEAT-082). Not a second // source of truth: it is a second render of the count this component already // holds, so it cannot drift from what the dropdown shows, and it follows the - // bell through the poll, a live socket write and marking everything read. + // bell through the poll, a live socket write, marking read and clearing. useEffect(() => setAppBadge(unread), [unread]); // A click on an OS notification lands here too (FEAT-083). `sw.js` focuses @@ -94,9 +99,18 @@ export function NotificationBell() { }, }); + const clearAll = useMutation({ + mutationFn: () => api.clearNotifications(), + onSuccess: () => { + queryClient.setQueryData(NOTIFICATIONS_KEY, { items: [], unread: 0 }); + setConfirmingClear(false); + }, + }); + const toggle = () => { const next = !open; setOpen(next); + if (!next) setConfirmingClear(false); // Opening the list *is* reading it. The badge is there to pull the user in, // so leaving it lit after they looked would train them to ignore it. if (next && unread > 0) markRead.mutate(undefined); @@ -138,16 +152,37 @@ export function NotificationBell() { Notifications - {items.length > 0 && ( - - )} + {items.length > 0 && + (confirmingClear ? ( + + Clear all? + + + + ) : ( + + ))} {items.length === 0 && ( diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 5f534fc11..09a6f207d 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -4273,6 +4273,10 @@ export const api = { body: JSON.stringify({ ids: ids ?? null }), }), + /** Delete all of this user's notifications. */ + clearNotifications: () => + apiFetch<{ unread: number }>("/api/v1/notifications", { method: "DELETE" }), + // ── Web Push (FEAT-083) ── // // The bell with the window closed. Every one of these is scoped to the JWT diff --git a/tests/test_notifications.py b/tests/test_notifications.py index 5daa29af9..c6f83b866 100644 --- a/tests/test_notifications.py +++ b/tests/test_notifications.py @@ -121,6 +121,20 @@ async def three(): assert unread_count(USER_A) == 0 +def test_clear_empties_only_own_bucket(store): + async def go(): + await record(USER_A, "A1") + await record(USER_A, "A2") + await record(USER_B, "B1") + await notifications.clear(USER_A) + + asyncio.run(go()) + + assert list_for(USER_A) == [] + assert unread_count(USER_A) == 0 + assert [n.text for n in list_for(USER_B)] == ["B1"] + + def test_no_user_records_nothing(store): """A producer with nobody behind it is not an error.""" assert asyncio.run(record(0, "orphan")) is None From 1f239afd4e0b274fbbc275217ffde6dc7df2c864 Mon Sep 17 00:00:00 2001 From: david-hummingbot Date: Tue, 13 Oct 2026 23:11:44 +0800 Subject: [PATCH 2/5] fix(notifications): address review feedback on Clear all - clear() no longer swallows a failed write; the route logs it and returns 500, so the bell never shows success for a delete that did not happen - cancel an in-flight list refresh before clearing, and re-read the store afterwards instead of leaving an empty snapshot that could hide a notice recorded just after the delete - opening a notification closes the bell through close(), so the confirm step is disarmed on every way out of the dropdown - show an error message when the clear fails, with Clear still available to retry - tests: backend failure path, and a component test for the confirm, error and disarm behaviour --- condor/notifications.py | 8 +- condor/web/routes/notifications.py | 12 +- .../layout/NotificationBell.test.tsx | 189 ++++++++++++++++++ .../components/layout/NotificationBell.tsx | 49 ++++- tests/test_notifications.py | 23 +++ 5 files changed, 264 insertions(+), 17 deletions(-) create mode 100644 frontend/src/components/layout/NotificationBell.test.tsx diff --git a/condor/notifications.py b/condor/notifications.py index bd8cbc87c..d9928ce4b 100644 --- a/condor/notifications.py +++ b/condor/notifications.py @@ -228,6 +228,9 @@ async def clear(user_id: int) -> None: Takes the write lock so a notice recorded mid-clear is not resurrected (or lost) by the two writes racing. Scoped like :func:`mark_read`: other users' buckets are left untouched. + + A failed write raises rather than being swallowed: unlike a read flag, a + clear the user was told succeeded would quietly come back on the next poll. """ if not user_id: return @@ -237,10 +240,7 @@ async def clear(user_id: int) -> None: if key not in data: return del data[key] - try: - _write_all(data) - except Exception: # noqa: BLE001 - log.warning("Could not clear notifications for %s", user_id, exc_info=True) + _write_all(data) def user_for_chat(chat_id: Any) -> int | None: diff --git a/condor/web/routes/notifications.py b/condor/web/routes/notifications.py index d40d1a934..db7393cb3 100644 --- a/condor/web/routes/notifications.py +++ b/condor/web/routes/notifications.py @@ -11,13 +11,17 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Query +import logging + +from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel from condor import notifications from condor.web.auth import get_current_user from condor.web.models import WebUser +log = logging.getLogger(__name__) + router = APIRouter(prefix="/notifications", tags=["notifications"]) @@ -51,5 +55,9 @@ async def mark_notifications_read( @router.delete("") async def clear_notifications(user: WebUser = Depends(get_current_user)): """Delete all of this user's notifications.""" - await notifications.clear(user.id) + try: + await notifications.clear(user.id) + except Exception: # noqa: BLE001 - the path belongs in the log, not the response + log.exception("Could not clear notifications for %s", user.id) + raise HTTPException(status_code=500, detail="Could not clear notifications") return {"unread": 0} diff --git a/frontend/src/components/layout/NotificationBell.test.tsx b/frontend/src/components/layout/NotificationBell.test.tsx new file mode 100644 index 000000000..432338473 --- /dev/null +++ b/frontend/src/components/layout/NotificationBell.test.tsx @@ -0,0 +1,189 @@ +/** + * The bell's "Clear all" (PR #281). + * + * Clearing deletes the history for good, so what is pinned here is that it + * only ever happens on a deliberate second click, that a failure says so, and + * that the list shown afterwards is the store's and not a guess. + * + * Needs a DOM, so this file overrides vitest's default `node` environment. + * + * @vitest-environment jsdom + */ + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { act, type ReactNode } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { AppNotification, NotificationsResponse } from "@/lib/api"; + +const getNotifications = vi.fn(); +const markNotificationsRead = vi.fn(); +const clearNotifications = vi.fn(); + +vi.mock("@/lib/api", () => ({ + api: { + getNotifications: (...a: unknown[]) => getNotifications(...a), + markNotificationsRead: (...a: unknown[]) => markNotificationsRead(...a), + clearNotifications: (...a: unknown[]) => clearNotifications(...a), + }, +})); + +vi.mock("@/lib/auth", () => ({ useAuth: () => ({ token: "t" }) })); +vi.mock("@/lib/appBadge", () => ({ setAppBadge: () => {} })); +vi.mock("@/lib/push", () => ({ onPushNavigate: () => () => {} })); +// The socket hook is a whole subsystem; the bell only needs its cache key. +vi.mock("@/hooks/useChatSocket", () => ({ NOTIFICATIONS_KEY: ["notifications"] })); +// Positioning is AnchoredMenu's own concern (and its own tests'); here it only +// has to show its children while open. +vi.mock("@/components/ui/AnchoredMenu", () => ({ + AnchoredMenu: ({ open, children }: { open: boolean; children: ReactNode }) => + open ?
{children}
: null, +})); + +const { NotificationBell } = await import("./NotificationBell"); + +declare global { + var IS_REACT_ACT_ENVIRONMENT: boolean; +} + +function note(over: Partial = {}): AppNotification { + return { + id: "n1", + user_id: 1, + ts: Date.now() / 1000, + kind: "system", + text: "Condor is online and ready.", + title: null, + link: null, + read: true, + ...over, + } as AppNotification; +} + +function listing(items: AppNotification[]): NotificationsResponse { + return { items, unread: items.filter((n) => !n.read).length }; +} + +let container: HTMLDivElement; +let root: Root; + +async function flush() { + await act(async () => { + await new Promise((r) => setTimeout(r, 0)); + }); +} + +function byLabel(label: string): HTMLButtonElement | null { + return container.querySelector(`button[title="${label}"]`); +} + +function byText(text: string): HTMLButtonElement | null { + return ( + Array.from(container.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === text, + ) ?? null + ); +} + +async function click(el: Element | null) { + expect(el).not.toBeNull(); + await act(async () => { + (el as HTMLElement).click(); + }); + await flush(); +} + +async function render() { + const client = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }); + await act(async () => { + root.render( + + + + + , + ); + }); + await flush(); +} + +const openBell = () => click(container.querySelector('button[aria-haspopup="menu"]')); + +beforeEach(() => { + globalThis.IS_REACT_ACT_ENVIRONMENT = true; + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + getNotifications.mockReset(); + markNotificationsRead.mockReset().mockResolvedValue({ unread: 0 }); + clearNotifications.mockReset(); +}); + +afterEach(() => { + act(() => root.unmount()); + container.remove(); +}); + +describe("NotificationBell clear all", () => { + it("deletes only on the second click, then re-reads the store", async () => { + getNotifications.mockResolvedValue(listing([note()])); + clearNotifications.mockResolvedValue({ unread: 0 }); + await render(); + await openBell(); + + await click(byLabel("Clear all notifications")); + expect(clearNotifications).not.toHaveBeenCalled(); + + // The store has moved on by the time it is re-read: one arrived after the delete. + getNotifications.mockResolvedValue(listing([note({ id: "n2", text: "Arrived after" })])); + const readsBefore = getNotifications.mock.calls.length; + + await click(byText("Clear")); + + expect(clearNotifications).toHaveBeenCalledTimes(1); + expect(getNotifications.mock.calls.length).toBeGreaterThan(readsBefore); + expect(container.textContent).toContain("Arrived after"); + expect(container.textContent).not.toContain("Condor is online"); + }); + + it("says so when the clear fails, and keeps Clear available to retry", async () => { + getNotifications.mockResolvedValue(listing([note()])); + clearNotifications.mockRejectedValue(new Error("500")); + await render(); + await openBell(); + + await click(byLabel("Clear all notifications")); + await click(byText("Clear")); + + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "Couldn't clear notifications", + ); + expect(container.textContent).toContain("Condor is online"); + const retry = byText("Clear"); + expect(retry).not.toBeNull(); + expect(retry?.disabled).toBe(false); + }); + + it("disarms the confirm when a notification is opened", async () => { + getNotifications.mockResolvedValue(listing([note({ link: "/bots" })])); + await render(); + await openBell(); + + await click(byLabel("Clear all notifications")); + expect(byText("Clear")).not.toBeNull(); + + const item = Array.from(container.querySelectorAll("button")).find((b) => + b.textContent?.includes("Condor is online and ready."), + ); + await click(item ?? null); + await openBell(); + + expect(byText("Clear")).toBeNull(); + expect(byLabel("Clear all notifications")).not.toBeNull(); + expect(clearNotifications).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/components/layout/NotificationBell.tsx b/frontend/src/components/layout/NotificationBell.tsx index 303af1366..e5741efca 100644 --- a/frontend/src/components/layout/NotificationBell.tsx +++ b/frontend/src/components/layout/NotificationBell.tsx @@ -50,10 +50,6 @@ export function NotificationBell() { const [anchor, setAnchor] = useState(null); // Clearing deletes the history for good, so it takes a second click. const [confirmingClear, setConfirmingClear] = useState(false); - const close = useCallback(() => { - setOpen(false); - setConfirmingClear(false); - }, []); const navigate = useNavigate(); const queryClient = useQueryClient(); @@ -100,25 +96,50 @@ export function NotificationBell() { }); const clearAll = useMutation({ - mutationFn: () => api.clearNotifications(), + mutationFn: async () => { + // A poll already in flight was read before the delete; letting it land + // afterwards would put the cleared history straight back. + await queryClient.cancelQueries({ queryKey: NOTIFICATIONS_KEY }); + return api.clearNotifications(); + }, onSuccess: () => { queryClient.setQueryData(NOTIFICATIONS_KEY, { items: [], unread: 0 }); setConfirmingClear(false); + // The empty list is a guess; the store is the answer. A notice recorded + // just after the delete may already have been pushed and then wiped by + // the line above, so re-read rather than leave the guess standing. + void queryClient.invalidateQueries({ queryKey: NOTIFICATIONS_KEY }); }, }); + // Every way out of the dropdown disarms the confirm, so a reopened bell never + // shows a Clear that deletes on its first click. + const resetClear = clearAll.reset; + const close = useCallback(() => { + setOpen(false); + setConfirmingClear(false); + resetClear(); + }, [resetClear]); + + const cancelClear = () => { + setConfirmingClear(false); + clearAll.reset(); + }; + const toggle = () => { - const next = !open; - setOpen(next); - if (!next) setConfirmingClear(false); + if (open) { + close(); + return; + } + setOpen(true); // Opening the list *is* reading it. The badge is there to pull the user in, // so leaving it lit after they looked would train them to ignore it. - if (next && unread > 0) markRead.mutate(undefined); + if (unread > 0) markRead.mutate(undefined); }; const openItem = (n: AppNotification) => { if (!n.link) return; - setOpen(false); + close(); navigate(n.link); }; @@ -166,7 +187,7 @@ export function NotificationBell() { Clear