diff --git a/web/src/lib/__tests__/handleUiAction.test.ts b/web/src/lib/__tests__/handleUiAction.test.ts new file mode 100644 index 00000000..fafd83b6 --- /dev/null +++ b/web/src/lib/__tests__/handleUiAction.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it, vi } from "vitest"; +import { handleUiAction } from "../handleUiAction"; + +describe("handleUiAction", () => { + it("surfaces a rejected action without rethrowing it", async () => { + const setError = vi.fn(); + + await expect( + handleUiAction(() => Promise.reject(new Error("boom")), setError) + ).resolves.toBeUndefined(); + + expect(setError).toHaveBeenCalledOnce(); + expect(setError).toHaveBeenCalledWith("boom"); + }); + + it("converts non-Error rejections to a readable message", async () => { + const setError = vi.fn(); + + await handleUiAction(() => Promise.reject("offline"), setError); + + expect(setError).toHaveBeenCalledWith("offline"); + }); +}); diff --git a/web/src/lib/handleUiAction.ts b/web/src/lib/handleUiAction.ts new file mode 100644 index 00000000..95086650 --- /dev/null +++ b/web/src/lib/handleUiAction.ts @@ -0,0 +1,10 @@ +export async function handleUiAction( + action: () => Promise, + setError: (message: string) => void +): Promise { + try { + await action(); + } catch (error) { + setError(error instanceof Error ? error.message : String(error)); + } +} diff --git a/web/src/pages/Events.tsx b/web/src/pages/Events.tsx index 762becc2..2a490b00 100644 --- a/web/src/pages/Events.tsx +++ b/web/src/pages/Events.tsx @@ -6,6 +6,7 @@ import { WebhookRegistrationData, } from "../lib/api"; import { useTranslation } from "react-i18next"; +import { handleUiAction } from "../lib/handleUiAction"; function fmtTime(value: string | number | null): string { if (value === null) return "-"; @@ -419,7 +420,10 @@ export function Events() { className="btn-ghost btn-sm" onClick={() => { setFilterType(type); - api.eventsList({ type, limit: 100 }).then((result) => setEvents(result.data.events)); + void handleUiAction(async () => { + const result = await api.eventsList({ type, limit: 100 }); + setEvents(result.data.events); + }, setError); }} > {type} diff --git a/web/src/pages/Network.tsx b/web/src/pages/Network.tsx index 5ccbb02e..ef167ceb 100644 --- a/web/src/pages/Network.tsx +++ b/web/src/pages/Network.tsx @@ -10,6 +10,7 @@ import { } from "../lib/api"; import { toast } from "../lib/toast-store"; import { useTranslation } from "react-i18next"; +import { handleUiAction } from "../lib/handleUiAction"; const TRUST_LEVELS: NetworkTrustLevel[] = ["trusted", "verified", "untrusted"]; const AGENT_STATUSES: NetworkAgentStatus[] = ["available", "busy", "offline", "degraded"]; @@ -327,18 +328,24 @@ export function Network() { }; const updateTrust = async (agent: NetworkAgentData, trustLevel: NetworkTrustLevel) => { - await api.updateNetworkAgentTrust(agent.id, { trustLevel }); - await load(); + await handleUiAction(async () => { + await api.updateNetworkAgentTrust(agent.id, { trustLevel }); + await load(); + }, setLastError); }; const toggleBlocked = async (agent: NetworkAgentData) => { - await api.updateNetworkAgentTrust(agent.id, { blocked: !agent.blocked }); - await load(); + await handleUiAction(async () => { + await api.updateNetworkAgentTrust(agent.id, { blocked: !agent.blocked }); + await load(); + }, setLastError); }; const removeAgent = async (agent: NetworkAgentData) => { - await api.removeNetworkAgent(agent.id); - await load(); + await handleUiAction(async () => { + await api.removeNetworkAgent(agent.id); + await load(); + }, setLastError); }; const delegateTask = async () => {