feat: add Apprise availability rescan (#3551)

This commit is contained in:
Carlos Florêncio
2026-09-09 20:05:46 +01:00
committed by GitHub
parent 2afab22705
commit 1eaa21bf45
26 changed files with 1567 additions and 400 deletions
@@ -24,6 +24,8 @@ const mocks = vi.hoisted(() => ({
}));
const PAGER_URL = "json://pager";
const CLARIFICATION_EVENT = "session.clarification_requested";
const SAVED_EVENT = "task.completed";
const DRAFT_NAME = "Draft name";
const SEMANTIC_NOTIFICATION_EVENTS = [CLARIFICATION_EVENT, "session.turn_finished"];
vi.mock("@/lib/api", () => ({
@@ -39,21 +41,29 @@ const savedProvider: NotificationProvider = {
type: "apprise",
config: { urls: ["json://saved"] },
enabled: true,
events: ["task.completed"],
events: [SAVED_EVENT],
created_at: "",
updated_at: "",
};
const localProvider: NotificationProvider = {
...savedProvider,
id: "provider-local",
name: "Local provider",
type: "local",
config: {},
};
let notificationProviders = [savedProvider];
let notificationEvents = ["task.completed"];
let notificationEvents = [SAVED_EVENT];
let notificationProvidersLoaded = true;
let notificationAppriseAvailable = true;
const originalNotification = globalThis.Notification;
vi.mock("@/hooks/domains/settings/use-notification-providers", () => ({
useNotificationProviders: () => ({
providers: notificationProviders,
events: notificationEvents,
appriseAvailable: true,
appriseAvailable: notificationAppriseAvailable,
loaded: notificationProvidersLoaded,
}),
}));
@@ -90,8 +100,9 @@ const createdProvider: NotificationProvider = {
beforeEach(() => {
vi.clearAllMocks();
notificationProviders = [savedProvider];
notificationEvents = ["task.completed"];
notificationEvents = [SAVED_EVENT];
notificationProvidersLoaded = true;
notificationAppriseAvailable = true;
mocks.nativeAvailable.mockReturnValue(true);
mocks.createNotificationProvider.mockResolvedValue(createdProvider);
});
@@ -152,19 +163,19 @@ describe("notification provider draft hydration", () => {
it("does not replace an edited draft when providers refresh", () => {
const { result, rerender } = renderHook(useHarness);
act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, "Draft name"));
act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME));
const refreshedProvider = { ...savedProvider, name: "Server refresh" };
notificationProviders = [refreshedProvider];
rerender();
expect(result.current.state.providers[0]?.name).toBe("Draft name");
expect(result.current.state.providers[0]?.name).toBe(DRAFT_NAME);
expect(result.current.state.baselineProviders[0]?.name).toBe(savedProvider.name);
});
it("hydrates a deferred provider refresh after discarding the draft", () => {
const { result, rerender } = renderHook(useHarness);
act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, "Draft name"));
act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME));
const refreshedProvider = { ...savedProvider, name: "Server refresh" };
notificationProviders = [refreshedProvider];
rerender();
@@ -174,6 +185,37 @@ describe("notification provider draft hydration", () => {
expect(result.current.state.providers).toEqual([refreshedProvider]);
expect(result.current.state.baselineProviders).toEqual([refreshedProvider]);
});
it("updates availability without replacing dirty drafts or pending removals", () => {
notificationProviders = [savedProvider, localProvider];
const { result, rerender } = renderHook(useHarness);
act(() => {
result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME);
result.current.actions.handleAppriseEdit(savedProvider.id, PAGER_URL);
result.current.actions.handleToggleEvent(savedProvider, CLARIFICATION_EVENT);
result.current.actions.handleDeleteProvider(localProvider.id);
result.current.actions.openAppriseForm("create");
result.current.state.setAppriseName("New provider");
result.current.state.setAppriseUrls("json://new");
});
notificationAppriseAvailable = false;
rerender();
expect(result.current.state.appriseAvailable).toBe(false);
expect(result.current.state.providers).toEqual([
expect.objectContaining({
id: savedProvider.id,
name: DRAFT_NAME,
config: { urls: [PAGER_URL] },
events: [SAVED_EVENT, CLARIFICATION_EVENT],
}),
]);
expect(result.current.state.pendingDeletes).toEqual(new Set([localProvider.id]));
expect(result.current.state.appriseName).toBe("New provider");
expect(result.current.state.appriseUrls).toBe("json://new");
});
});
describe("notification provider draft saving", () => {
@@ -246,6 +288,38 @@ describe("notification provider draft saving", () => {
expect(result.current.state.providers).toEqual([savedProvider]);
expect(result.current.isDirty).toBe(false);
});
it("does not overwrite a rescan that completes during a provider save", async () => {
let resolveUpdate: (provider: NotificationProvider) => void = () => undefined;
const updatePromise = new Promise<NotificationProvider>((resolve) => {
resolveUpdate = resolve;
});
mocks.updateNotificationProvider.mockReturnValueOnce(updatePromise);
const { result, rerender } = renderHook(useHarness);
act(() => result.current.actions.handleAppriseNameEdit(savedProvider.id, DRAFT_NAME));
let savePromise: Promise<unknown>;
act(() => {
savePromise = result.current.saveRequest.run();
});
await waitFor(() => expect(mocks.updateNotificationProvider).toHaveBeenCalledOnce());
notificationAppriseAvailable = false;
rerender();
await act(async () => {
resolveUpdate({ ...savedProvider, name: DRAFT_NAME });
await savePromise;
});
const lastSnapshot = mocks.setNotificationProviders.mock.lastCall?.[0];
expect(lastSnapshot).toEqual({
items: [{ ...savedProvider, name: DRAFT_NAME }],
events: [SAVED_EVENT],
loaded: true,
loading: false,
});
});
});
describe("notification permission actions", () => {
@@ -143,20 +143,80 @@ function isSameHydrationSource(
);
}
type NotificationDraftHydrationOptions = {
loaded: boolean;
source: NotificationHydrationSource;
draft: NotificationDraft;
setProviders: (value: NotificationProvider[]) => void;
setBaselineProviders: (value: NotificationProvider[]) => void;
setNotificationEvents: (value: string[]) => void;
setAppriseEdits: (value: Record<string, string>) => void;
setAppriseNameEdits: (value: Record<string, string>) => void;
};
function useHydrateNotificationDraft({
loaded,
source,
draft,
setProviders,
setBaselineProviders,
setNotificationEvents,
setAppriseEdits,
setAppriseNameEdits,
}: NotificationDraftHydrationOptions) {
const hydratedSource = useRef<NotificationHydrationSource | undefined>(undefined);
useEffect(() => {
if (!loaded || isSameHydrationSource(hydratedSource.current, source)) return;
if (hydratedSource.current && hasDraftChanges(draft)) return;
hydratedSource.current = source;
const nextProviders = source.providers ?? [];
setProviders(nextProviders);
setBaselineProviders(nextProviders);
setNotificationEvents(source.events ?? []);
const edits = buildAppriseEdits(nextProviders);
setAppriseEdits(edits.urls);
setAppriseNameEdits(edits.names);
}, [
draft.appriseEdits,
draft.appriseFormMode,
draft.appriseNameEdits,
draft.baselineProviders,
draft.pendingDeletes,
draft.providers,
draft.showAppriseForm,
loaded,
setAppriseEdits,
setAppriseNameEdits,
setBaselineProviders,
setNotificationEvents,
setProviders,
source.appriseAvailable,
source.events,
source.providers,
]);
}
export function useNotificationsState() {
const {
providers: storeProviders,
events: storeEvents,
appriseAvailable: storeAppriseAvailable,
loaded,
loading,
rescanApprise,
appriseRescanPending,
appriseRescanError,
appriseRescanResult,
} = useNotificationProviders();
const appriseAvailable = storeAppriseAvailable ?? false;
const setNotificationProviders = useAppStore((state) => state.setNotificationProviders);
const [providers, setProviders] = useState<NotificationProvider[]>(() => storeProviders ?? []);
const [baselineProviders, setBaselineProviders] = useState<NotificationProvider[]>(
() => storeProviders ?? [],
);
const [notificationEvents, setNotificationEvents] = useState<string[]>(() => storeEvents ?? []);
const [appriseAvailable, setAppriseAvailable] = useState(() => storeAppriseAvailable ?? true);
const [appriseName, setAppriseName] = useState("");
const [appriseUrls, setAppriseUrls] = useState("");
const [appriseEdits, setAppriseEdits] = useState<Record<string, string>>(
@@ -169,19 +229,14 @@ export function useNotificationsState() {
const [appriseFormMode, setAppriseFormMode] = useState<AppriseFormMode>("create");
const [activeAppriseId, setActiveAppriseId] = useState<string | null>(null);
const [pendingDeletes, setPendingDeletes] = useState<Set<string>>(new Set());
const hydratedSource = useRef<NotificationHydrationSource | undefined>(undefined);
useEffect(() => {
if (!loaded) return;
const source = {
useHydrateNotificationDraft({
loaded,
source: {
providers: storeProviders,
events: storeEvents,
appriseAvailable: storeAppriseAvailable,
};
if (isSameHydrationSource(hydratedSource.current, source)) {
return;
}
const draft = {
},
draft: {
providers,
baselineProviders,
appriseEdits,
@@ -189,32 +244,13 @@ export function useNotificationsState() {
pendingDeletes,
showAppriseForm,
appriseFormMode,
};
if (hydratedSource.current && hasDraftChanges(draft)) {
return;
}
hydratedSource.current = source;
const nextProviders = storeProviders ?? [];
setProviders(nextProviders);
setBaselineProviders(nextProviders);
setNotificationEvents(storeEvents ?? []);
setAppriseAvailable(storeAppriseAvailable ?? true);
const edits = buildAppriseEdits(nextProviders);
setAppriseEdits(edits.urls);
setAppriseNameEdits(edits.names);
}, [
appriseEdits,
appriseFormMode,
appriseNameEdits,
baselineProviders,
loaded,
pendingDeletes,
providers,
showAppriseForm,
storeAppriseAvailable,
storeEvents,
storeProviders,
]);
},
setProviders,
setBaselineProviders,
setNotificationEvents,
setAppriseEdits,
setAppriseNameEdits,
});
return {
providers,
setProviders,
@@ -222,8 +258,13 @@ export function useNotificationsState() {
setBaselineProviders,
notificationEvents,
setNotificationEvents,
loaded,
loading,
appriseAvailable,
setAppriseAvailable,
rescanApprise,
appriseRescanPending,
appriseRescanError,
appriseRescanResult,
appriseName,
setAppriseName,
appriseUrls,
@@ -304,7 +345,6 @@ export function useSaveRequest(state: NotificationsState) {
setNotificationProviders({
items: nextProviders,
events: state.notificationEvents,
appriseAvailable: state.appriseAvailable,
loaded: true,
loading: false,
});
@@ -0,0 +1,430 @@
"use client";
import { type FormEvent } from "react";
import { IconBell, IconRefresh } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Textarea } from "@kandev/ui/textarea";
import { SettingsTarget } from "@/components/settings/settings-target";
import { type AppriseFormMode } from "@/components/settings/notifications-settings-actions";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import type { NotificationProvider } from "@/lib/types/http";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
function AppriseProviderCardActions({
provider,
onOpenForm,
onDeleteProvider,
onTestProvider,
}: {
provider: NotificationProvider;
onOpenForm: (mode: AppriseFormMode, provider: NotificationProvider) => void;
onDeleteProvider: (providerId: string) => void;
onTestProvider: (providerId: string) => Promise<void>;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 cursor-pointer"
aria-label={t("settings:sendTestNotificationFor", { name: provider.name })}
onClick={() => void onTestProvider(provider.id)}
>
<IconBell className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings:sendTestNotification")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onOpenForm("edit", provider)}
>
{t("settings:edit")}
</Button>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onDeleteProvider(provider.id)}
>
{t("settings:remove")}
</Button>
</div>
);
}
function AppriseProviderList({
providers,
baselineProviders,
appriseFormMode,
activeAppriseId,
appriseName,
appriseUrls,
onNameChange,
onUrlsChange,
onAppriseNameEdit,
onAppriseEdit,
onOpenForm,
onCloseForm,
onCancelForm,
onDeleteProvider,
onTestProvider,
onTextareaInput,
}: {
providers: NotificationProvider[];
baselineProviders: NotificationProvider[];
appriseFormMode: AppriseFormMode;
activeAppriseId: string | null;
appriseName: string;
appriseUrls: string;
onNameChange: (value: string) => void;
onUrlsChange: (value: string) => void;
onAppriseNameEdit: (providerId: string, value: string) => void;
onAppriseEdit: (providerId: string, value: string) => void;
onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void;
onCloseForm: () => void;
onCancelForm: () => void;
onDeleteProvider: (providerId: string) => void;
onTestProvider: (providerId: string) => Promise<void>;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
}) {
return (
<>
{providers.map((provider) => {
const isEditing = appriseFormMode === "edit" && activeAppriseId === provider.id;
const baseline = baselineProviders.find((candidate) => candidate.id === provider.id);
const nameIsDirty = isEditing && provider.name !== baseline?.name;
const urlsIsDirty =
isEditing &&
JSON.stringify(provider.config?.urls ?? []) !==
JSON.stringify(baseline?.config?.urls ?? []);
return (
<div
key={provider.id}
className="rounded-lg border border-muted p-4 space-y-3"
data-settings-dirty={nameIsDirty || urlsIsDirty}
data-settings-dirty-level="container"
>
{isEditing ? (
<AppriseProviderForm
mode="edit"
name={appriseName}
urls={appriseUrls}
onNameChange={(value) => {
onNameChange(value);
onAppriseNameEdit(provider.id, value);
}}
onUrlsChange={(value) => {
onUrlsChange(value);
onAppriseEdit(provider.id, value);
}}
onSubmit={onCloseForm}
onCancel={onCancelForm}
onInput={onTextareaInput}
nameIsDirty={nameIsDirty}
urlsIsDirty={urlsIsDirty}
/>
) : (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1 flex-1">
<div className="font-medium">{provider.name}</div>
{/* Product name — never translated (docs/i18n.md "Do not translate"). */}
<div className="text-xs text-muted-foreground">Apprise</div>
</div>
<AppriseProviderCardActions
provider={provider}
onOpenForm={onOpenForm}
onDeleteProvider={onDeleteProvider}
onTestProvider={onTestProvider}
/>
</div>
)}
</div>
);
})}
</>
);
}
type ExternalProvidersSectionProps = {
appriseAvailable: boolean;
appriseProviders: NotificationProvider[];
baselineProviders: NotificationProvider[];
appriseFormMode: AppriseFormMode;
activeAppriseId: string | null;
appriseName: string;
appriseUrls: string;
showAppriseForm: boolean;
notificationProvidersLoaded: boolean;
notificationProvidersLoading: boolean;
appriseRescanPending: boolean;
appriseRescanError: boolean;
appriseRescanResult: boolean | null;
setAppriseName: (v: string) => void;
setAppriseUrls: (v: string) => void;
onAppriseNameEdit: (id: string, v: string) => void;
onAppriseEdit: (id: string, v: string) => void;
onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void;
onCloseForm: () => void;
onCancelForm: () => void;
onDeleteProvider: (id: string) => void;
onTestProvider: (id: string) => Promise<void>;
onRescanApprise: () => Promise<void>;
};
function AppriseRescanControls({
notificationProvidersLoaded,
notificationProvidersLoading,
appriseRescanPending,
appriseRescanError,
appriseRescanResult,
onRescanApprise,
}: Pick<
ExternalProvidersSectionProps,
| "notificationProvidersLoaded"
| "notificationProvidersLoading"
| "appriseRescanPending"
| "appriseRescanError"
| "appriseRescanResult"
| "onRescanApprise"
>) {
const { t } = useTranslation();
return (
<div className="flex min-w-0 flex-col items-start gap-1 md:items-end">
<Button
variant="outline"
className="min-h-11 cursor-pointer md:min-h-9 [@media(pointer:coarse)]:min-h-11"
data-testid="apprise-rescan"
disabled={
!notificationProvidersLoaded || notificationProvidersLoading || appriseRescanPending
}
onClick={() => void onRescanApprise()}
>
<IconRefresh className={appriseRescanPending ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
{appriseRescanPending ? t("settings:checkingApprise") : t("settings:rescanApprise")}
</Button>
<p className="text-xs text-muted-foreground">{t("settings:appriseDetectionServer")}</p>
{appriseRescanError && (
<p className="text-xs text-amber-600" role="alert" data-testid="apprise-rescan-error">
{t("settings:appriseRescanFailed")}
</p>
)}
{!appriseRescanPending && !appriseRescanError && appriseRescanResult !== null && (
<p
className="text-xs text-muted-foreground"
role="status"
data-testid="apprise-rescan-result"
>
{appriseRescanResult ? t("settings:appriseDetected") : t("settings:appriseNotDetected")}
</p>
)}
</div>
);
}
function AppriseProviderSectionBody({
appriseAvailable,
appriseProviders,
baselineProviders,
appriseFormMode,
activeAppriseId,
appriseName,
appriseUrls,
showAppriseForm,
setAppriseName,
setAppriseUrls,
onAppriseNameEdit,
onAppriseEdit,
onOpenForm,
onCloseForm,
onCancelForm,
onDeleteProvider,
onTestProvider,
}: Omit<
ExternalProvidersSectionProps,
| "notificationProvidersLoaded"
| "notificationProvidersLoading"
| "appriseRescanPending"
| "appriseRescanError"
| "appriseRescanResult"
| "onRescanApprise"
>) {
const { t } = useTranslation();
return (
<>
{!appriseAvailable && (
<p className="text-xs text-muted-foreground">
{/* The link is part of the sentence, so the whole notice is one
message and the <2> tag addresses the anchor by child index. */}
<Trans i18nKey="settings:appriseNotInstalled">
Apprise is not installed yet. You can add it later to enable remote notifications.{" "}
<a
className="underline"
href="https://github.com/caronc/apprise?tab=readme-ov-file#installation"
target="_blank"
rel="noreferrer"
>
View installation instructions
</a>
.
</Trans>
</p>
)}
{appriseProviders.length === 0 && (
<p className="text-xs text-muted-foreground">{t("settings:noAppriseProviders")}</p>
)}
<AppriseProviderList
providers={appriseProviders}
baselineProviders={baselineProviders}
appriseFormMode={appriseFormMode}
activeAppriseId={activeAppriseId}
appriseName={appriseName}
appriseUrls={appriseUrls}
onNameChange={setAppriseName}
onUrlsChange={setAppriseUrls}
onAppriseNameEdit={onAppriseNameEdit}
onAppriseEdit={onAppriseEdit}
onOpenForm={onOpenForm}
onCloseForm={onCloseForm}
onCancelForm={onCancelForm}
onDeleteProvider={onDeleteProvider}
onTestProvider={onTestProvider}
onTextareaInput={handleTextareaInput}
/>
<div className="space-y-3">
{appriseAvailable && (
<Button
variant="outline"
className="cursor-pointer"
onClick={() => onOpenForm("create")}
disabled={showAppriseForm}
>
{t("settings:addAppriseProvider")}
</Button>
)}
{showAppriseForm && appriseFormMode === "create" && (
<AppriseProviderForm
mode="create"
name={appriseName}
urls={appriseUrls}
onNameChange={setAppriseName}
onUrlsChange={setAppriseUrls}
onSubmit={onCloseForm}
onCancel={onCancelForm}
onInput={handleTextareaInput}
formIsDirty
nameIsDirty={appriseName.length > 0}
urlsIsDirty={appriseUrls.length > 0}
showSubmit={false}
/>
)}
</div>
</>
);
}
export function ExternalProvidersSection(props: ExternalProvidersSectionProps) {
const { t } = useTranslation();
return (
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.notificationProviders} className="space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<div className="text-sm font-medium">{t("settings:externalProviders")}</div>
<p className="text-xs text-muted-foreground" data-testid="external-providers-description">
{t("settings:externalProvidersDescription")}
</p>
</div>
<AppriseRescanControls
notificationProvidersLoaded={props.notificationProvidersLoaded}
notificationProvidersLoading={props.notificationProvidersLoading}
appriseRescanPending={props.appriseRescanPending}
appriseRescanError={props.appriseRescanError}
appriseRescanResult={props.appriseRescanResult}
onRescanApprise={props.onRescanApprise}
/>
</div>
<AppriseProviderSectionBody {...props} />
</SettingsTarget>
);
}
type AppriseProviderFormProps = {
mode: AppriseFormMode;
name: string;
urls: string;
onNameChange: (value: string) => void;
onUrlsChange: (value: string) => void;
onSubmit: () => void | Promise<void>;
onCancel: () => void;
onInput: (event: FormEvent<HTMLTextAreaElement>) => void;
nameIsDirty?: boolean;
urlsIsDirty?: boolean;
formIsDirty?: boolean;
showSubmit?: boolean;
};
function AppriseProviderForm({
mode,
name,
urls,
onNameChange,
onUrlsChange,
onSubmit,
onCancel,
onInput,
nameIsDirty = false,
urlsIsDirty = false,
formIsDirty = nameIsDirty || urlsIsDirty,
showSubmit = true,
}: AppriseProviderFormProps) {
const { t } = useTranslation();
return (
<div
className="rounded-lg border border-dashed border-muted p-4 space-y-3"
data-settings-dirty={formIsDirty}
data-settings-dirty-level="container"
>
<div className="text-sm font-medium">{t("settings:appriseProvider")}</div>
<Input
value={name}
onChange={(event) => onNameChange(event.target.value)}
placeholder={t("settings:providerName")}
data-settings-dirty={nameIsDirty}
/>
<Textarea
value={urls}
onChange={(event) => onUrlsChange(event.target.value)}
onInput={onInput}
placeholder={t("settings:appriseServiceUrls")}
rows={1}
className="min-h-0 h-auto"
data-settings-dirty={urlsIsDirty}
/>
<div className="flex items-center gap-2">
{showSubmit && (
<Button className="cursor-pointer" onClick={onSubmit}>
{mode === "create" ? t("settings:addProvider") : t("settings:done")}
</Button>
)}
<Button variant="ghost" className="cursor-pointer" onClick={onCancel}>
{t("settings:cancel")}
</Button>
</div>
</div>
);
}
function handleTextareaInput(event: FormEvent<HTMLTextAreaElement>) {
const t = event.currentTarget;
t.style.height = "auto";
t.style.height = `${t.scrollHeight}px`;
}
@@ -1,4 +1,4 @@
import { cleanup, render } from "@testing-library/react";
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { NotificationProvider } from "@/lib/types/http";
import { NotificationsSettings } from "./notifications-settings";
@@ -10,6 +10,7 @@ const mocks = vi.hoisted(() => ({
testNotificationProvider: vi.fn(),
updateNotificationProvider: vi.fn(),
setNotificationProviders: vi.fn(),
rescanApprise: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
@@ -23,13 +24,31 @@ vi.mock("@/lib/api", () => ({
// compares them by reference, so a fresh array per render re-hydrates forever.
const PROVIDERS: NotificationProvider[] = [];
const EVENTS = ["session.turn_finished"];
const APPRISE_PROVIDER: NotificationProvider = {
id: "apprise-provider",
name: "Saved Apprise",
type: "apprise",
config: { urls: ["json://saved"] },
enabled: true,
events: EVENTS,
created_at: "",
updated_at: "",
};
let appriseAvailable = false;
let appriseRescanPending = false;
let appriseRescanError = false;
let appriseRescanResult: boolean | null = null;
vi.mock("@/hooks/domains/settings/use-notification-providers", () => ({
useNotificationProviders: () => ({
providers: PROVIDERS,
events: EVENTS,
appriseAvailable: false,
appriseAvailable,
loaded: true,
rescanApprise: mocks.rescanApprise,
appriseRescanPending,
appriseRescanError,
appriseRescanResult,
}),
}));
@@ -45,7 +64,14 @@ vi.mock("@/lib/desktop/native-notification-client", () => ({
},
}));
afterEach(cleanup);
afterEach(() => {
cleanup();
PROVIDERS.length = 0;
appriseAvailable = false;
appriseRescanPending = false;
appriseRescanError = false;
appriseRescanResult = null;
});
describe("NotificationsSettings", () => {
it("renders the Apprise install notice as one sentence around its link", () => {
@@ -92,4 +118,82 @@ describe("NotificationsSettings", () => {
.filter((className) => /(?:^|\s)text-(?:base|lg|\d?xl)(?=[\s/]|$)/.test(className));
expect(oversized).toEqual([]);
});
it("rescans Apprise and exposes the fresh detection result", () => {
const { rerender } = render(
<SettingsSaveProvider>
<NotificationsSettings />
</SettingsSaveProvider>,
);
const rescan = screen.getByRole("button", { name: "Rescan Apprise" });
expect(rescan).toBeTruthy();
expect(screen.getByText(/Detection runs on the Kandev server/)).toBeTruthy();
fireEvent.click(rescan);
expect(mocks.rescanApprise).toHaveBeenCalledOnce();
appriseRescanPending = true;
rerender(
<SettingsSaveProvider>
<NotificationsSettings />
</SettingsSaveProvider>,
);
expect(
(screen.getByRole("button", { name: "Checking Apprise" }) as HTMLButtonElement).disabled,
).toBe(true);
appriseAvailable = true;
appriseRescanPending = false;
appriseRescanResult = true;
rerender(
<SettingsSaveProvider>
<NotificationsSettings />
</SettingsSaveProvider>,
);
expect(screen.getByText("Apprise detected.")).toBeTruthy();
expect(screen.getByRole("button", { name: "Add Apprise Provider" })).toBeTruthy();
});
it("keeps the last availability and offers retry feedback after a rescan error", () => {
const { rerender } = render(
<SettingsSaveProvider>
<NotificationsSettings />
</SettingsSaveProvider>,
);
appriseRescanError = true;
rerender(
<SettingsSaveProvider>
<NotificationsSettings />
</SettingsSaveProvider>,
);
expect(screen.getByText("Could not check Apprise. Try again.")).toBeTruthy();
expect(screen.getByRole("button", { name: "Rescan Apprise" })).toBeTruthy();
expect(screen.getByText(/Apprise is not installed yet/)).toBeTruthy();
});
it("routes edit Cancel through the draft rollback handler", () => {
PROVIDERS.push(APPRISE_PROVIDER);
appriseAvailable = true;
render(
<SettingsSaveProvider>
<NotificationsSettings />
</SettingsSaveProvider>,
);
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
fireEvent.change(screen.getByDisplayValue("Saved Apprise"), {
target: { value: "Changed Apprise" },
});
fireEvent.change(screen.getByDisplayValue("json://saved"), {
target: { value: "json://changed" },
});
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(screen.getAllByText("Saved Apprise").length).toBeGreaterThan(0);
expect(screen.queryByDisplayValue("Changed Apprise")).toBeNull();
expect(screen.queryByDisplayValue("json://changed")).toBeNull();
});
});
@@ -1,18 +1,12 @@
"use client";
import { useMemo, useState, type FormEvent } from "react";
import { IconBell } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Separator } from "@kandev/ui/separator";
import { Textarea } from "@kandev/ui/textarea";
import { NotificationSoundSection } from "@/components/settings/notification-sound-section";
import { NotificationEventsTable } from "@/components/settings/notification-events-table";
import { SettingsPageTemplate } from "@/components/settings/settings-page-template";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { DEFAULT_NOTIFICATION_EVENTS } from "@/lib/notifications/events";
import type { NotificationProvider } from "@/lib/types/http";
import {
DesktopNotificationsSection,
useNotificationPermission,
@@ -23,274 +17,11 @@ import {
useNotificationsActions,
useIsDirty,
type NotificationsState,
type AppriseFormMode,
} from "@/components/settings/notifications-settings-actions";
import { ExternalProvidersSection } from "@/components/settings/notifications-settings-external-providers";
import { SettingsTarget } from "@/components/settings/settings-target";
import { GENERAL_SETTINGS_TARGETS } from "@/lib/settings-discovery/catalog/preferences";
function AppriseProviderCardActions({
provider,
onOpenForm,
onDeleteProvider,
onTestProvider,
}: {
provider: NotificationProvider;
onOpenForm: (mode: AppriseFormMode, provider: NotificationProvider) => void;
onDeleteProvider: (providerId: string) => void;
onTestProvider: (providerId: string) => Promise<void>;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-2">
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="outline"
size="icon"
className="h-8 w-8 cursor-pointer"
aria-label={t("settings:sendTestNotificationFor", { name: provider.name })}
onClick={() => void onTestProvider(provider.id)}
>
<IconBell className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>{t("settings:sendTestNotification")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onOpenForm("edit", provider)}
>
{t("settings:edit")}
</Button>
<Button
variant="outline"
size="sm"
className="cursor-pointer"
onClick={() => onDeleteProvider(provider.id)}
>
{t("settings:remove")}
</Button>
</div>
);
}
function AppriseProviderList({
providers,
baselineProviders,
appriseFormMode,
activeAppriseId,
appriseName,
appriseUrls,
onNameChange,
onUrlsChange,
onAppriseNameEdit,
onAppriseEdit,
onOpenForm,
onCloseForm,
onDeleteProvider,
onTestProvider,
onTextareaInput,
}: {
providers: NotificationProvider[];
baselineProviders: NotificationProvider[];
appriseFormMode: AppriseFormMode;
activeAppriseId: string | null;
appriseName: string;
appriseUrls: string;
onNameChange: (value: string) => void;
onUrlsChange: (value: string) => void;
onAppriseNameEdit: (providerId: string, value: string) => void;
onAppriseEdit: (providerId: string, value: string) => void;
onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void;
onCloseForm: () => void;
onCancelForm: () => void;
onDeleteProvider: (providerId: string) => void;
onTestProvider: (providerId: string) => Promise<void>;
onTextareaInput: (event: FormEvent<HTMLTextAreaElement>) => void;
}) {
return (
<>
{providers.map((provider) => {
const isEditing = appriseFormMode === "edit" && activeAppriseId === provider.id;
const baseline = baselineProviders.find((candidate) => candidate.id === provider.id);
const nameIsDirty = isEditing && provider.name !== baseline?.name;
const urlsIsDirty =
isEditing &&
JSON.stringify(provider.config?.urls ?? []) !==
JSON.stringify(baseline?.config?.urls ?? []);
return (
<div
key={provider.id}
className="rounded-lg border border-muted p-4 space-y-3"
data-settings-dirty={nameIsDirty || urlsIsDirty}
data-settings-dirty-level="container"
>
{isEditing ? (
<AppriseProviderForm
mode="edit"
name={appriseName}
urls={appriseUrls}
onNameChange={(value) => {
onNameChange(value);
onAppriseNameEdit(provider.id, value);
}}
onUrlsChange={(value) => {
onUrlsChange(value);
onAppriseEdit(provider.id, value);
}}
onSubmit={onCloseForm}
onCancel={onCloseForm}
onInput={onTextareaInput}
nameIsDirty={nameIsDirty}
urlsIsDirty={urlsIsDirty}
/>
) : (
<div className="flex items-center justify-between gap-4">
<div className="space-y-1 flex-1">
<div className="font-medium">{provider.name}</div>
{/* Product name — never translated (docs/i18n.md "Do not translate"). */}
<div className="text-xs text-muted-foreground">Apprise</div>
</div>
<AppriseProviderCardActions
provider={provider}
onOpenForm={onOpenForm}
onDeleteProvider={onDeleteProvider}
onTestProvider={onTestProvider}
/>
</div>
)}
</div>
);
})}
</>
);
}
type ExternalProvidersSectionProps = {
appriseAvailable: boolean;
appriseProviders: NotificationProvider[];
baselineProviders: NotificationProvider[];
appriseFormMode: AppriseFormMode;
activeAppriseId: string | null;
appriseName: string;
appriseUrls: string;
showAppriseForm: boolean;
setAppriseName: (v: string) => void;
setAppriseUrls: (v: string) => void;
onAppriseNameEdit: (id: string, v: string) => void;
onAppriseEdit: (id: string, v: string) => void;
onOpenForm: (mode: AppriseFormMode, provider?: NotificationProvider) => void;
onCloseForm: () => void;
onCancelForm: () => void;
onDeleteProvider: (id: string) => void;
onTestProvider: (id: string) => Promise<void>;
onTextareaInput: (e: FormEvent<HTMLTextAreaElement>) => void;
};
function ExternalProvidersSection({
appriseAvailable,
appriseProviders,
baselineProviders,
appriseFormMode,
activeAppriseId,
appriseName,
appriseUrls,
showAppriseForm,
setAppriseName,
setAppriseUrls,
onAppriseNameEdit,
onAppriseEdit,
onOpenForm,
onCloseForm,
onCancelForm,
onDeleteProvider,
onTestProvider,
onTextareaInput,
}: ExternalProvidersSectionProps) {
const { t } = useTranslation();
return (
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.notificationProviders} className="space-y-4">
<div>
<div className="text-sm font-medium">{t("settings:externalProviders")}</div>
<p className="text-xs text-muted-foreground" data-testid="external-providers-description">
{t("settings:externalProvidersDescription")}
</p>
</div>
{!appriseAvailable && (
<p className="text-xs text-muted-foreground">
{/* The link is part of the sentence, so the whole notice is one
message and the <2> tag addresses the anchor by child index. */}
<Trans i18nKey="settings:appriseNotInstalled">
Apprise is not installed yet. You can add it later to enable remote notifications.{" "}
<a
className="underline"
href="https://github.com/caronc/apprise?tab=readme-ov-file#installation"
target="_blank"
rel="noreferrer"
>
View installation instructions
</a>
.
</Trans>
</p>
)}
{appriseProviders.length === 0 && (
<p className="text-xs text-muted-foreground">{t("settings:noAppriseProviders")}</p>
)}
<AppriseProviderList
providers={appriseProviders}
baselineProviders={baselineProviders}
appriseFormMode={appriseFormMode}
activeAppriseId={activeAppriseId}
appriseName={appriseName}
appriseUrls={appriseUrls}
onNameChange={setAppriseName}
onUrlsChange={setAppriseUrls}
onAppriseNameEdit={onAppriseNameEdit}
onAppriseEdit={onAppriseEdit}
onOpenForm={onOpenForm}
onCloseForm={onCloseForm}
onCancelForm={onCancelForm}
onDeleteProvider={onDeleteProvider}
onTestProvider={onTestProvider}
onTextareaInput={onTextareaInput}
/>
{appriseAvailable && (
<div className="space-y-3">
<Button
variant="outline"
className="cursor-pointer"
onClick={() => onOpenForm("create")}
disabled={showAppriseForm}
>
{t("settings:addAppriseProvider")}
</Button>
{showAppriseForm && appriseFormMode === "create" && (
<AppriseProviderForm
mode="create"
name={appriseName}
urls={appriseUrls}
onNameChange={setAppriseName}
onUrlsChange={setAppriseUrls}
onSubmit={onCloseForm}
onCancel={onCancelForm}
onInput={onTextareaInput}
formIsDirty
nameIsDirty={appriseName.length > 0}
urlsIsDirty={appriseUrls.length > 0}
showSubmit={false}
/>
)}
</div>
)}
</SettingsTarget>
);
}
function useTableData(state: NotificationsState) {
const { providers, notificationEvents } = state;
const tableProviders = useMemo(
@@ -360,6 +91,12 @@ export function NotificationsSettings() {
providers,
baselineProviders,
appriseAvailable,
loaded: notificationProvidersLoaded,
loading: notificationProvidersLoading,
rescanApprise,
appriseRescanPending,
appriseRescanError,
appriseRescanResult,
appriseName,
setAppriseName,
appriseUrls,
@@ -400,6 +137,11 @@ export function NotificationsSettings() {
appriseName={appriseName}
appriseUrls={appriseUrls}
showAppriseForm={showAppriseForm}
notificationProvidersLoaded={notificationProvidersLoaded}
notificationProvidersLoading={notificationProvidersLoading}
appriseRescanPending={appriseRescanPending}
appriseRescanError={appriseRescanError}
appriseRescanResult={appriseRescanResult}
setAppriseName={setAppriseName}
setAppriseUrls={setAppriseUrls}
onAppriseNameEdit={actions.handleAppriseNameEdit}
@@ -409,7 +151,7 @@ export function NotificationsSettings() {
onCancelForm={actions.cancelAppriseForm}
onDeleteProvider={actions.handleDeleteProvider}
onTestProvider={actions.handleTestProvider}
onTextareaInput={handleTextareaInput}
onRescanApprise={rescanApprise}
/>
<Separator className="my-4" />
<SettingsTarget targetId={GENERAL_SETTINGS_TARGETS.notificationEvents} className="space-y-4">
@@ -432,75 +174,3 @@ export function NotificationsSettings() {
</SettingsPageTemplate>
);
}
type AppriseProviderFormProps = {
mode: AppriseFormMode;
name: string;
urls: string;
onNameChange: (value: string) => void;
onUrlsChange: (value: string) => void;
onSubmit: () => void | Promise<void>;
onCancel: () => void;
onInput: (event: FormEvent<HTMLTextAreaElement>) => void;
nameIsDirty?: boolean;
urlsIsDirty?: boolean;
formIsDirty?: boolean;
showSubmit?: boolean;
};
function AppriseProviderForm({
mode,
name,
urls,
onNameChange,
onUrlsChange,
onSubmit,
onCancel,
onInput,
nameIsDirty = false,
urlsIsDirty = false,
formIsDirty = nameIsDirty || urlsIsDirty,
showSubmit = true,
}: AppriseProviderFormProps) {
const { t } = useTranslation();
return (
<div
className="rounded-lg border border-dashed border-muted p-4 space-y-3"
data-settings-dirty={formIsDirty}
data-settings-dirty-level="container"
>
<div className="text-sm font-medium">{t("settings:appriseProvider")}</div>
<Input
value={name}
onChange={(event) => onNameChange(event.target.value)}
placeholder={t("settings:providerName")}
data-settings-dirty={nameIsDirty}
/>
<Textarea
value={urls}
onChange={(event) => onUrlsChange(event.target.value)}
onInput={onInput}
placeholder={t("settings:appriseServiceUrls")}
rows={1}
className="min-h-0 h-auto"
data-settings-dirty={urlsIsDirty}
/>
<div className="flex items-center gap-2">
{showSubmit && (
<Button className="cursor-pointer" onClick={onSubmit}>
{mode === "create" ? t("settings:addProvider") : t("settings:done")}
</Button>
)}
<Button variant="ghost" className="cursor-pointer" onClick={onCancel}>
{t("settings:cancel")}
</Button>
</div>
</div>
);
}
function handleTextareaInput(event: FormEvent<HTMLTextAreaElement>) {
const t = event.currentTarget;
t.style.height = "auto";
t.style.height = `${t.scrollHeight}px`;
}
@@ -0,0 +1,67 @@
import type { Page } from "@playwright/test";
export const APPRISE_EVENT = "session.turn_finished";
export const APPRISE_PROVIDER_NAME = "E2E local notifications";
type RescanOutcome = boolean | "error";
function providerResponse(appriseAvailable: boolean) {
return {
providers: [
{
id: "e2e-local-notification-provider",
name: APPRISE_PROVIDER_NAME,
type: "local",
config: {},
enabled: true,
events: [],
created_at: "2026-09-09T00:00:00Z",
updated_at: "2026-09-09T00:00:00Z",
},
],
apprise_available: appriseAvailable,
events: [APPRISE_EVENT],
};
}
export async function routeAppriseRescans(
page: Page,
outcomes: RescanOutcome[],
): Promise<{ startRescan: () => void }> {
let rescanStarted = false;
let outcomeIndex = 0;
await page.route("**/api/v1/notification-providers**", async (route) => {
if (route.request().method() !== "GET") {
await route.continue();
return;
}
if (!rescanStarted) {
await route.fulfill({
status: 200,
contentType: "application/json",
json: providerResponse(false),
});
return;
}
const outcome = outcomes[Math.min(outcomeIndex++, Math.max(outcomes.length - 1, 0))] ?? false;
if (outcome === "error") {
await route.fulfill({
status: 503,
contentType: "application/json",
json: { error: "provider list unavailable" },
});
return;
}
await route.fulfill({
status: 200,
contentType: "application/json",
json: providerResponse(outcome),
});
});
return {
startRescan: () => {
rescanStarted = true;
},
};
}
@@ -0,0 +1,49 @@
import { test, expect } from "../../fixtures/test-base";
import { APPRISE_PROVIDER_NAME, routeAppriseRescans } from "./apprise-rescan-helpers";
test.describe("Apprise rescan", () => {
test("refreshes availability, preserves an event draft, and supports retry", async ({
testPage,
prCapture,
}) => {
const { startRescan } = await routeAppriseRescans(testPage, [true, "error", false]);
await testPage.goto("/settings/preferences/notifications");
const rescan = testPage.getByTestId("apprise-rescan");
const event = testPage.getByRole("checkbox", {
name: `Agent turn finished for ${APPRISE_PROVIDER_NAME}`,
});
await expect(testPage.getByTestId("notification-events-desktop-table")).toBeVisible();
await expect(rescan).toBeVisible();
await expect(testPage.getByRole("button", { name: "Add Apprise Provider" })).toHaveCount(0);
await expect(event).not.toBeChecked();
await event.click();
await expect(event).toBeChecked();
await expect(testPage.getByRole("button", { name: "Save changes" })).toBeVisible();
startRescan();
await rescan.click();
await expect(testPage.getByText("Apprise detected.", { exact: true })).toBeVisible();
await expect(testPage.getByRole("button", { name: "Add Apprise Provider" })).toBeVisible();
await expect(event).toBeChecked();
await expect(testPage.getByRole("button", { name: "Save changes" })).toBeVisible();
await prCapture.screenshot("apprise-rescan-desktop", {
caption: "Desktop Apprise detection and preserved notification draft",
});
await rescan.click();
await expect(testPage.getByTestId("apprise-rescan-error")).toHaveText(
"Could not check Apprise. Try again.",
);
await expect(testPage.getByRole("button", { name: "Add Apprise Provider" })).toBeVisible();
await rescan.click();
await expect(testPage.getByText("Apprise was not detected.", { exact: true })).toBeVisible();
await expect(testPage.getByRole("button", { name: "Add Apprise Provider" })).toHaveCount(0);
await expect(event).toBeChecked();
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth > window.innerWidth),
).toBe(false);
});
});
@@ -0,0 +1,52 @@
import { test, expect } from "../../fixtures/test-base";
import { APPRISE_PROVIDER_NAME, routeAppriseRescans } from "./apprise-rescan-helpers";
test.describe("Mobile Apprise rescan", () => {
test("detects Apprise with a touch-sized action and no horizontal overflow", async ({
testPage,
prCapture,
}) => {
const { startRescan } = await routeAppriseRescans(testPage, [true]);
await testPage.goto("/settings/preferences/notifications");
const rescan = testPage.getByTestId("apprise-rescan");
await expect(rescan).toBeVisible();
const beforeBox = await rescan.boundingBox();
expect(beforeBox).not.toBeNull();
expect(beforeBox!.height).toBeGreaterThanOrEqual(44);
startRescan();
await rescan.tap();
await expect(testPage.getByText("Apprise detected.", { exact: true })).toBeVisible();
await expect(testPage.getByRole("button", { name: "Add Apprise Provider" })).toBeVisible();
await expect(
testPage.getByRole("checkbox", { name: `Agent turn finished for ${APPRISE_PROVIDER_NAME}` }),
).toBeVisible();
await prCapture.screenshot("apprise-rescan-mobile", {
caption: "Mobile Apprise detection with a touch-sized rescan action",
});
const afterBox = await rescan.boundingBox();
expect(afterBox).not.toBeNull();
expect(afterBox!.height).toBeGreaterThanOrEqual(44);
expect(
await testPage.evaluate(() => document.documentElement.scrollWidth > window.innerWidth),
).toBe(false);
});
test("keeps the rescan action touch-sized on a coarse tablet", async ({ tabletTestPage }) => {
await routeAppriseRescans(tabletTestPage, [true]);
await tabletTestPage.goto("/settings/preferences/notifications");
await expect.poll(() => tabletTestPage.evaluate(() => window.innerWidth)).toBe(900);
await expect
.poll(() => tabletTestPage.evaluate(() => matchMedia("(pointer: coarse)").matches))
.toBe(true);
const rescan = tabletTestPage.getByTestId("apprise-rescan");
await expect(rescan).toBeVisible();
const box = await rescan.boundingBox();
expect(box).not.toBeNull();
expect(box!.height).toBeGreaterThanOrEqual(44);
});
});
@@ -0,0 +1,186 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useNotificationProviders } from "./use-notification-providers";
const mocks = vi.hoisted(() => ({
listNotificationProviders: vi.fn(),
setAppriseAvailable: vi.fn(),
setNotificationProviders: vi.fn(),
setNotificationProvidersLoading: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
listNotificationProviders: mocks.listNotificationProviders,
}));
const storeState = {
notificationProviders: {
items: [],
events: [],
appriseAvailable: false,
loaded: true,
loading: false,
},
};
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({
...storeState,
setAppriseAvailable: mocks.setAppriseAvailable,
setNotificationProviders: mocks.setNotificationProviders,
setNotificationProvidersLoading: mocks.setNotificationProvidersLoading,
}),
}));
function providerResponse(appriseAvailable: boolean) {
return {
providers: [],
events: [],
apprise_available: appriseAvailable,
};
}
beforeEach(() => {
vi.clearAllMocks();
storeState.notificationProviders.appriseAvailable = false;
mocks.listNotificationProviders.mockReset();
});
async function assertStaleRescanAfterUnmountIsIgnored() {
let resolveA: (value: ReturnType<typeof providerResponse>) => void = () => undefined;
let resolveB: (value: ReturnType<typeof providerResponse>) => void = () => undefined;
const requestA = new Promise<ReturnType<typeof providerResponse>>((resolve) => {
resolveA = resolve;
});
const requestB = new Promise<ReturnType<typeof providerResponse>>((resolve) => {
resolveB = resolve;
});
mocks.listNotificationProviders.mockReturnValueOnce(requestA).mockReturnValueOnce(requestB);
const first = renderHook(() => useNotificationProviders());
act(() => {
void first.result.current.rescanApprise();
});
await waitFor(() => expect(first.result.current.appriseRescanPending).toBe(true));
first.unmount();
const second = renderHook(() => useNotificationProviders());
act(() => {
void second.result.current.rescanApprise();
});
await waitFor(() => expect(mocks.listNotificationProviders).toHaveBeenCalledTimes(2));
await act(async () => {
resolveB(providerResponse(true));
await requestB;
});
expect(mocks.setAppriseAvailable).toHaveBeenCalledWith(true);
expect(second.result.current.appriseRescanResult).toBe(true);
await act(async () => {
resolveA(providerResponse(false));
await requestA;
});
expect(mocks.setAppriseAvailable).toHaveBeenCalledTimes(1);
expect(mocks.setAppriseAvailable).toHaveBeenLastCalledWith(true);
expect(second.result.current.appriseRescanResult).toBe(true);
}
describe("useNotificationProviders Apprise rescan", () => {
it.each([
[false, true],
[true, false],
])("updates availability from %s to %s", async (initial, next) => {
storeState.notificationProviders.appriseAvailable = initial;
mocks.listNotificationProviders.mockResolvedValueOnce(providerResponse(next));
const { result } = renderHook(() => useNotificationProviders());
await act(async () => {
await result.current.rescanApprise();
});
expect(mocks.listNotificationProviders).toHaveBeenCalledWith({ cache: "no-store" });
expect(mocks.setAppriseAvailable).toHaveBeenCalledWith(next);
expect(result.current.appriseRescanResult).toBe(next);
expect(result.current.appriseRescanError).toBe(false);
});
it("blocks duplicate requests while the first request is pending", async () => {
let resolveRequest: (value: ReturnType<typeof providerResponse>) => void = () => undefined;
const request = new Promise<ReturnType<typeof providerResponse>>((resolve) => {
resolveRequest = resolve;
});
mocks.listNotificationProviders.mockReturnValueOnce(request);
const { result } = renderHook(() => useNotificationProviders());
act(() => {
void result.current.rescanApprise();
void result.current.rescanApprise();
});
await waitFor(() => expect(result.current.appriseRescanPending).toBe(true));
expect(mocks.listNotificationProviders).toHaveBeenCalledOnce();
await act(async () => {
resolveRequest(providerResponse(true));
await request;
});
await waitFor(() => expect(result.current.appriseRescanPending).toBe(false));
});
it("retains the last availability after failure and retries successfully", async () => {
storeState.notificationProviders.appriseAvailable = true;
mocks.listNotificationProviders
.mockRejectedValueOnce(new Error("backend unavailable"))
.mockResolvedValueOnce(providerResponse(false));
const { result } = renderHook(() => useNotificationProviders());
await act(async () => {
await result.current.rescanApprise();
});
expect(result.current.appriseRescanError).toBe(true);
expect(result.current.appriseRescanResult).toBeNull();
expect(mocks.setAppriseAvailable).not.toHaveBeenCalled();
await act(async () => {
await result.current.rescanApprise();
});
expect(mocks.setAppriseAvailable).toHaveBeenCalledWith(false);
expect(result.current.appriseRescanError).toBe(false);
expect(result.current.appriseRescanResult).toBe(false);
});
it("updates only availability when a delayed response resolves", async () => {
let resolveRequest: (value: ReturnType<typeof providerResponse>) => void = () => undefined;
const request = new Promise<ReturnType<typeof providerResponse>>((resolve) => {
resolveRequest = resolve;
});
mocks.listNotificationProviders.mockReturnValueOnce(request);
const { result } = renderHook(() => useNotificationProviders());
act(() => {
void result.current.rescanApprise();
});
await waitFor(() => expect(result.current.appriseRescanPending).toBe(true));
await act(async () => {
resolveRequest(providerResponse(true));
await request;
});
expect(mocks.setAppriseAvailable).toHaveBeenCalledWith(true);
expect(mocks.setNotificationProviders).not.toHaveBeenCalled();
});
it(
"ignores a stale rescan after the owning hook unmounts",
assertStaleRescanAfterUnmountIsIgnored,
);
});
@@ -1,6 +1,6 @@
"use client";
import { useEffect } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { listNotificationProviders } from "@/lib/api";
import { useAppStore } from "@/components/state-provider";
@@ -14,6 +14,19 @@ export function useNotificationProviders() {
const setNotificationProvidersLoading = useAppStore(
(state) => state.setNotificationProvidersLoading,
);
const setAppriseAvailable = useAppStore((state) => state.setAppriseAvailable);
const [appriseRescanPending, setAppriseRescanPending] = useState(false);
const [appriseRescanError, setAppriseRescanError] = useState(false);
const [appriseRescanResult, setAppriseRescanResult] = useState<boolean | null>(null);
const appriseRescanInFlight = useRef(false);
const appriseRescanRequestId = useRef(0);
useEffect(() => {
return () => {
// A delayed response must not publish into a later settings-page instance.
appriseRescanRequestId.current += 1;
};
}, []);
useEffect(() => {
if (loaded || loading) return;
@@ -42,11 +55,37 @@ export function useNotificationProviders() {
});
}, [loaded, loading, setNotificationProviders, setNotificationProvidersLoading]);
const rescanApprise = useCallback(async () => {
if (loading || appriseRescanInFlight.current) return;
const requestId = ++appriseRescanRequestId.current;
appriseRescanInFlight.current = true;
setAppriseRescanPending(true);
setAppriseRescanError(false);
try {
const response = await listNotificationProviders({ cache: "no-store" });
if (requestId !== appriseRescanRequestId.current) return;
const available = response.apprise_available ?? false;
setAppriseAvailable(available);
setAppriseRescanResult(available);
} catch {
if (requestId !== appriseRescanRequestId.current) return;
setAppriseRescanError(true);
} finally {
if (requestId !== appriseRescanRequestId.current) return;
appriseRescanInFlight.current = false;
setAppriseRescanPending(false);
}
}, [loading, setAppriseAvailable]);
return {
providers,
events,
appriseAvailable,
loaded,
loading,
rescanApprise,
appriseRescanPending,
appriseRescanError,
appriseRescanResult,
};
}
+7 -3
View File
@@ -17,7 +17,11 @@ import type { AgentRuntimeAvailability } from "@/lib/types/agent-runtime";
import type { AgentProfileRecentUseContext } from "@/lib/types/http-agent-profile-recent-use";
import type { UISliceActions as UIA } from "./slices/ui/types";
import type * as UISliceTypes from "./slices/ui/types";
import type { AgentUpdateJob, InstallJob } from "./slices/settings/types";
import type {
AgentUpdateJob,
InstallJob,
NotificationProvidersUpdate,
} from "./slices/settings/types";
import {
defaultWorkspaceState,
defaultSettingsState,
@@ -47,7 +51,6 @@ import type {
EditorsState,
PromptsState,
SecretsState,
NotificationProvidersState,
SettingsDataState,
SleepInhibitionStoreState,
UserSettingsState,
@@ -313,7 +316,8 @@ export type AppState = KanbanSlice & {
setSpritesInstances: (instances: import("@/lib/types/http-sprites").SpritesInstance[]) => void;
setSpritesLoading: (loading: boolean) => void;
removeSpritesInstance: (name: string) => void;
setNotificationProviders: (state: NotificationProvidersState) => void;
setNotificationProviders: (state: NotificationProvidersUpdate) => void;
setAppriseAvailable: (available: boolean) => void;
setNotificationProvidersLoading: (loading: boolean) => void;
setSleepInhibition: (response: NonNullable<SleepInhibitionStoreState["response"]>) => void;
setSleepInhibitionLoading: (loading: boolean) => void;
@@ -52,6 +52,38 @@ describe("user settings snapshots", () => {
});
});
describe("notification provider availability", () => {
it("changes only Apprise availability", () => {
const store = makeStore();
const before = store.getState().notificationProviders;
// @covers AC-PLATFORM-APPRISE-RESCAN-001.4
store.getState().setAppriseAvailable(true);
const after = store.getState().notificationProviders;
expect(after.appriseAvailable).toBe(true);
expect(after.items).toBe(before.items);
expect(after.events).toBe(before.events);
expect(after.loaded).toBe(before.loaded);
expect(after.loading).toBe(before.loading);
});
it("preserves availability when a provider update omits it", () => {
const store = makeStore();
store.getState().setAppriseAvailable(true);
const before = store.getState().notificationProviders;
store.getState().setNotificationProviders({
items: before.items,
events: before.events,
loaded: true,
loading: false,
});
expect(store.getState().notificationProviders.appriseAvailable).toBe(true);
});
});
describe("settings update jobs", () => {
it("rehydrates the newest retained job for each agent", () => {
const store = makeStore();
@@ -349,6 +349,7 @@ function createSecretAndSpriteActions(
| "setSpritesLoading"
| "removeSpritesInstance"
| "setNotificationProviders"
| "setAppriseAvailable"
| "setNotificationProvidersLoading"
> {
return {
@@ -399,10 +400,16 @@ function createSecretAndSpriteActions(
set((draft) => {
draft.notificationProviders.items = state.items;
draft.notificationProviders.events = state.events;
draft.notificationProviders.appriseAvailable = state.appriseAvailable;
if (state.appriseAvailable !== undefined) {
draft.notificationProviders.appriseAvailable = state.appriseAvailable;
}
draft.notificationProviders.loaded = state.loaded;
draft.notificationProviders.loading = state.loading;
}),
setAppriseAvailable: (available) =>
set((draft) => {
draft.notificationProviders.appriseAvailable = available;
}),
setNotificationProvidersLoading: (loading) =>
set((draft) => {
draft.notificationProviders.loading = loading;
+6 -1
View File
@@ -382,6 +382,10 @@ export type NotificationProvidersState = {
loading: boolean;
};
export type NotificationProvidersUpdate = Omit<NotificationProvidersState, "appriseAvailable"> & {
appriseAvailable?: boolean;
};
export type SettingsDataState = {
executorsLoaded: boolean;
agentsLoaded: boolean;
@@ -528,7 +532,8 @@ export type SettingsSliceActions = {
setSpritesInstances: (instances: SpritesInstance[]) => void;
setSpritesLoading: (loading: boolean) => void;
removeSpritesInstance: (name: string) => void;
setNotificationProviders: (state: NotificationProvidersState) => void;
setNotificationProviders: (state: NotificationProvidersUpdate) => void;
setAppriseAvailable: (available: boolean) => void;
setNotificationProvidersLoading: (loading: boolean) => void;
setSettingsData: (next: Partial<SettingsDataState>) => void;
setSleepInhibition: (response: SleepInhibitionResponse) => void;
+6
View File
@@ -37,6 +37,11 @@
"appriseProvider": "Apprise Provider",
"appriseServiceUrls": "Service URL(s)",
"appriseUrlRequired": "At least one Apprise service URL is required.",
"appriseDetected": "Apprise detected.",
"appriseDetectionServer": "Detection runs on the Kandev server.",
"appriseNotDetected": "Apprise was not detected.",
"appriseRescanFailed": "Could not check Apprise. Try again.",
"rescanApprise": "Rescan Apprise",
"archiveConfirmation": "Archive Confirmation",
"preventAutoStartAgentOnOpen": "Prevent auto-start on open",
"preventAutoStartAgentOnOpenHelp": "Don't start the agent automatically when opening a task after a restart or from the final workflow step. The Start agent button is shown instead.",
@@ -129,6 +134,7 @@
"deleteSecret": "Delete secret",
"cannotDeleteSecret": "Cannot delete secret",
"checkingSecretReferences": "Checking where this secret is used…",
"checkingApprise": "Checking Apprise",
"secretReferencesLoadFailed": "Couldn't check where this secret is used",
"secretDeleteFailed": "Couldn't delete secret",
"secretInUse": "This secret is in use by: {{references}}. Remove or replace these references before deleting it.",
@@ -37,6 +37,11 @@
"appriseProvider": "Àƥƥŕĩśē Ƥŕōvĩďēŕ",
"appriseServiceUrls": "Śēŕvĩćē ŨŔĹ(ś)",
"appriseUrlRequired": "Àţ ĺēàśţ ōńē Àƥƥŕĩśē śēŕvĩćē ŨŔĹ ĩś ŕēqũĩŕēď.",
"appriseDetected": "Àƥƥŕĩśē ďēţēćţēď.",
"appriseDetectionServer": "Ďēţēćţĩōń ŕũńś ōń ţĥē Ķàńďēv śēŕvēŕ.",
"appriseNotDetected": "Àƥƥŕĩśē ŵàś ńōţ ďēţēćţēď.",
"appriseRescanFailed": "Ćōũĺď ńōţ ćĥēćķ Àƥƥŕĩśē. Ţŕŷ àĝàĩń.",
"rescanApprise": "Ŕēśćàń Àƥƥŕĩśē",
"archiveConfirmation": "Àŕćĥĩvē Ćōńƒĩŕḿàţĩōń",
"preventAutoStartAgentOnOpen": "Ƥŕēvēńţ àũţō-śţàŕţ ōń ōƥēń",
"preventAutoStartAgentOnOpenHelp": "Ďōń'ţ śţàŕţ ţĥē àĝēńţ àũţōḿàţĩćàĺĺŷ ŵĥēń ōƥēńĩńĝ à ţàśķ àƒţēŕ à ŕēśţàŕţ ōŕ ƒŕōḿ ţĥē ƒĩńàĺ ŵōŕķƒĺōŵ śţēƥ. Ţĥē Śţàŕţ àĝēńţ ƀũţţōń ĩś śĥōŵń ĩńśţēàď.",
@@ -129,6 +134,7 @@
"deleteSecret": "Ďēĺēţē śēćŕēţ",
"cannotDeleteSecret": "Ćàńńōţ ďēĺēţē śēćŕēţ",
"checkingSecretReferences": "Ćĥēćķĩńĝ ŵĥēŕē ţĥĩś śēćŕēţ ĩś ũśēď…",
"checkingApprise": "Ćĥēćķĩńĝ Àƥƥŕĩśē",
"secretReferencesLoadFailed": "Ćōũĺďń'ţ ćĥēćķ ŵĥēŕē ţĥĩś śēćŕēţ ĩś ũśēď",
"secretDeleteFailed": "Ćōũĺďń'ţ ďēĺēţē śēćŕēţ",
"secretInUse": "Ţĥĩś śēćŕēţ ĩś ĩń ũśē ƀŷ: {{references}}. Ŕēḿōvē ōŕ ŕēƥĺàćē ţĥēśē ŕēƒēŕēńćēś ƀēƒōŕē ďēĺēţĩńĝ ĩţ.",
+6
View File
@@ -36,6 +36,11 @@
"appriseProvider": "Fornecedor Apprise",
"appriseServiceUrls": "URL(s) do serviço",
"appriseUrlRequired": "É obrigatório pelo menos um URL de serviço Apprise.",
"appriseDetected": "Apprise detetado.",
"appriseDetectionServer": "A deteção é executada no servidor Kandev.",
"appriseNotDetected": "O Apprise não foi detetado.",
"appriseRescanFailed": "Não foi possível verificar o Apprise. Tente novamente.",
"rescanApprise": "Procurar o Apprise novamente",
"archiveConfirmation": "Confirmação de arquivo",
"preventAutoStartAgentOnOpen": "Impedir arranque automático ao abrir",
"preventAutoStartAgentOnOpenHelp": "Não inicia o agente automaticamente ao abrir uma tarefa após um reinício ou a partir do passo final do fluxo de trabalho. O botão Iniciar o agente é mostrado em vez disso.",
@@ -126,6 +131,7 @@
"deleteSecret": "Eliminar segredo",
"cannotDeleteSecret": "Não é possível eliminar o segredo",
"checkingSecretReferences": "A verificar onde este segredo é utilizado…",
"checkingApprise": "A verificar o Apprise",
"secretReferencesLoadFailed": "Não foi possível verificar onde este segredo é utilizado",
"secretDeleteFailed": "Não foi possível eliminar o segredo",
"secretInUse": "Este segredo está em uso por: {{references}}. Remova ou substitua estas referências antes de o eliminar.",
+6
View File
@@ -37,6 +37,11 @@
"appriseProvider": "Apprise 提供商",
"appriseServiceUrls": "服务 URL",
"appriseUrlRequired": "至少需要一个 Apprise 服务 URL。",
"appriseDetected": "已检测到 Apprise。",
"appriseDetectionServer": "检测在 Kandev 服务器上运行。",
"appriseNotDetected": "未检测到 Apprise。",
"appriseRescanFailed": "无法检查 Apprise。请重试。",
"rescanApprise": "重新检测 Apprise",
"archiveConfirmation": "归档确认",
"preventAutoStartAgentOnOpen": "打开任务时不自动启动智能体",
"preventAutoStartAgentOnOpenHelp": "重启后,或从工作流最后一步打开任务时,不会自动启动智能体,改为显示“启动智能体”按钮。",
@@ -129,6 +134,7 @@
"deleteSecret": "删除密钥",
"cannotDeleteSecret": "无法删除密钥",
"checkingSecretReferences": "正在检查此密钥的使用位置…",
"checkingApprise": "正在检查 Apprise",
"secretReferencesLoadFailed": "无法检查此密钥的使用位置",
"secretDeleteFailed": "无法删除密钥",
"secretInUse": "以下位置正在使用此密钥:{{references}}。删除密钥前,请移除或替换这些引用。",
+6
View File
@@ -37,6 +37,11 @@
"appriseProvider": "Apprise 提供者",
"appriseServiceUrls": "服務 URL",
"appriseUrlRequired": "至少需要一個 Apprise 服務 URL。",
"appriseDetected": "已檢測到 Apprise。",
"appriseDetectionServer": "檢測在 Kandev 伺服器上執行。",
"appriseNotDetected": "未檢測到 Apprise。",
"appriseRescanFailed": "無法檢查 Apprise。請重試。",
"rescanApprise": "重新檢測 Apprise",
"archiveConfirmation": "歸檔確認",
"preventAutoStartAgentOnOpen": "開啟任務時不自動啓動代理程式",
"preventAutoStartAgentOnOpenHelp": "重新啓動後,或從工作流程最後一步開啟任務時,不會自動啓動代理程式,改為顯示「啓動代理程式」按鈕。",
@@ -129,6 +134,7 @@
"deleteSecret": "刪除密鑰",
"cannotDeleteSecret": "無法刪除密鑰",
"checkingSecretReferences": "正在檢查此密鑰的使用位置…",
"checkingApprise": "正在檢查 Apprise",
"secretReferencesLoadFailed": "無法檢查此密鑰的使用位置",
"secretDeleteFailed": "無法刪除密鑰",
"secretInUse": "以下位置正在使用此密鑰:{{references}}。刪除密鑰前,請移除或替換這些引用。",
+6
View File
@@ -37,6 +37,11 @@
"appriseProvider": "Apprise 提供者",
"appriseServiceUrls": "服務 URL",
"appriseUrlRequired": "至少需要一個 Apprise 服務 URL。",
"appriseDetected": "已檢測到 Apprise。",
"appriseDetectionServer": "檢測在 Kandev 伺服器上執行。",
"appriseNotDetected": "未檢測到 Apprise。",
"appriseRescanFailed": "無法檢查 Apprise。請重試。",
"rescanApprise": "重新檢測 Apprise",
"archiveConfirmation": "歸檔確認",
"preventAutoStartAgentOnOpen": "開啟任務時不自動啟動代理程式",
"preventAutoStartAgentOnOpenHelp": "重新啟動後,或從工作流程最後一步開啟任務時,不會自動啟動代理程式,改為顯示「啟動代理程式」按鈕。",
@@ -129,6 +134,7 @@
"deleteSecret": "刪除金鑰",
"cannotDeleteSecret": "無法刪除金鑰",
"checkingSecretReferences": "正在檢查此金鑰的使用位置…",
"checkingApprise": "正在檢查 Apprise",
"secretReferencesLoadFailed": "無法檢查此金鑰的使用位置",
"secretDeleteFailed": "無法刪除金鑰",
"secretInUse": "以下位置正在使用此金鑰:{{references}}。刪除金鑰前,請移除或替換這些引用。",
+79
View File
@@ -0,0 +1,79 @@
---
created: 2026-09-09
status: done
requirements:
- REQ-PLATFORM-APPRISE-RESCAN-001
system_design:
- ../../specs/platform/system-design/apprise-rescan.md
legacy_specs: []
---
# Implementation Plan: Apprise Rescan
## Overview
Deliver one vertical change: detect a newly installed Apprise from Notifications
settings while preserving drafts. Execute one work order sequentially.
## Scope
In scope: manual detection, pending/result/error feedback, draft preservation,
translations, desktop/mobile proof, and a short public how-to.
Out of scope: installation, imported configuration, automatic polling, new
endpoints, notification delivery, and unrelated settings changes.
## Technical approach
Follow the [system design](../../specs/platform/system-design/apprise-rescan.md).
Extend `useNotificationProviders` with an availability-only refresh. Add a
narrow store setter and separate availability from guarded draft hydration.
Wire a labeled button in `ExternalProvidersSection` and preserve open forms.
The backend already evaluates `exec.LookPath("apprise")` for every list request.
## Tests
- `use-notification-providers.test.tsx`: missing-to-detected, detected-to-missing,
duplicate clicks, failed request followed by retry, and delayed response after
provider data changes (AC 001.2 through 001.4).
- `notifications-settings-actions.test.tsx`: dirty name, URL, event, removal,
and create-form drafts survive changed availability (AC 001.4).
- `notifications-settings.test.tsx`: action in both availability states,
feedback, unchanged save dirtiness, and retained open form (AC 001.1-001.4).
## E2E tests
- `settings/apprise-rescan.spec.ts`, project `chromium`: click Rescan Apprise,
observe fresh detection, and retain an unsaved event selection; cover retry
after a failed request (AC 001.1-001.4).
- `settings/mobile-apprise-rescan.spec.ts`, project `mobile-chrome`: tap the
action, observe detection and Add Apprise Provider, assert a 44px touch target
and no document overflow (AC 001.2, 001.5).
Use controlled GET responses and deferred completion for deterministic checks.
Do not install Apprise or send remote notifications from browser tests.
## Work orders
- [x] [Task 01: Add Apprise rescan](task-01-apprise-rescan.md) (`done`)
## Verification results
Implementation completed.
- Targeted Vitest: 4 files, 37 tests passed.
- TypeScript typecheck passed.
- Targeted ESLint passed with zero warnings.
- `i18n:zh-hant` and `i18n:check` passed for all required catalogs.
- Managed Chromium and mobile-chrome E2E scenarios passed, including fresh
desktop and mobile captures.
- Public-doc tests and validation passed for 46 pages.
- Specification lint and `git diff --check` passed.
## Risks
- An executable installed outside the running backend's PATH stays undetected.
- Updating the full provider store from a delayed rescan could revert a save;
the availability-only setter prevents that overwrite.
- Both notifications source files are already large. Extract only a focused
control if needed to meet lint limits; avoid broad component refactoring.
@@ -0,0 +1,128 @@
---
id: "01-apprise-rescan"
title: "Add Apprise rescan"
status: done
wave: 1
depends_on: []
plan: "plan.md"
requirements:
- REQ-PLATFORM-APPRISE-RESCAN-001
acceptance_criteria:
- AC-PLATFORM-APPRISE-RESCAN-001.1
- AC-PLATFORM-APPRISE-RESCAN-001.2
- AC-PLATFORM-APPRISE-RESCAN-001.3
- AC-PLATFORM-APPRISE-RESCAN-001.4
- AC-PLATFORM-APPRISE-RESCAN-001.5
system_design:
- ../../specs/platform/system-design/apprise-rescan.md
---
# Task 01: Add Apprise Rescan
## Summary
Add the Notifications settings rescan action using the existing provider-list
API. Apply only availability updates and preserve editable settings.
## In scope
- Domain hook, narrow store setter, availability hydration, and control wiring.
- Loading, result, error/retry, draft preservation, and translations.
- Targeted tests, mobile geometry, and a public Notifications how-to section.
## Out of scope
Installation, new API contracts, automatic polling, sending notifications,
schema changes, and broad settings refactoring.
## Acceptance
1. The labeled action updates detection in both directions; duplicate calls
are blocked and a failed request retains status until a successful retry.
2. Dirty settings and open forms survive every result; a delayed rescan cannot
overwrite provider data saved while it was pending.
3. Desktop and mobile flows pass targeted tests, all copy is localized, and the
public instructions explain the backend environment boundary.
## Verification
Use TDD for changed logic. From `apps/`, install once before package commands:
```bash
rtk pnpm install --frozen-lockfile
```
From `apps/web/`:
```bash
rtk pnpm exec vitest run hooks/domains/settings/use-notification-providers.test.tsx components/settings/notifications-settings-actions.test.tsx components/settings/notifications-settings.test.tsx
rtk pnpm run typecheck
rtk pnpm exec eslint hooks/domains/settings/use-notification-providers.ts components/settings/notifications-settings-actions.ts components/settings/notifications-settings.tsx lib/state/slices/settings/settings-slice.ts lib/state/slices/settings/types.ts
rtk pnpm run i18n:zh-hant
rtk pnpm run i18n:check
rtk pnpm e2e:run --project chromium tests/settings/apprise-rescan.spec.ts
rtk pnpm e2e:run --project mobile-chrome tests/settings/mobile-apprise-rescan.spec.ts
```
Include any extracted production component in the targeted lint command.
Use normal managed builds and inspect rendered desktop/mobile captures.
From the repository root:
```bash
rtk node --test scripts/validate-public-docs.test.mjs
rtk node scripts/validate-public-docs.mjs
rtk python3 scripts/lint-spec-files.py --all
rtk git diff --check
```
## Files likely touched
- `apps/web/hooks/domains/settings/use-notification-providers.ts` and new test.
- `apps/web/lib/state/slices/settings/settings-slice.ts` and `types.ts`.
- `apps/web/components/settings/notifications-settings-actions.ts` and its test.
- `apps/web/components/settings/notifications-settings.tsx`, the extracted
external-provider section, and its test.
- `apps/web/src/locales/{en,pt-pt,zh-cn,zh-hk,zh-tw}/settings.json`.
- `apps/web/e2e/tests/settings/{apprise-rescan,mobile-apprise-rescan}.spec.ts`
and a shared helper if needed.
- `docs/public/developer-tools.md`.
## Dependencies
None.
## Risks
See the plan. Keep create-form input mounted when availability becomes false.
Use the narrow store setter so captured provider arrays cannot revert a save.
## Parallelism
`sequential`
## Inputs
- [Requirements](../../specs/platform/requirements/apprise-rescan.md).
- [System design](../../specs/platform/system-design/apprise-rescan.md).
- Existing `notification-permission-section.tsx` refresh control.
- Existing `mobile-notification-events.spec.ts` for settings fixture and geometry.
- Repository `/tdd`, `/mobile-parity`, `/e2e`, and `/docs-maintainer` guidance.
## Results
Implemented with an availability-only rescan path and localized pending,
result, and retry feedback. The store setter updates only Apprise availability,
so provider drafts, removals, open forms, and concurrent provider saves are not
replaced.
Verification completed:
- `pnpm install --frozen-lockfile` completed from `apps/`.
- Targeted Vitest passed: 4 files, 37 tests.
- Typecheck passed.
- Targeted ESLint passed with `--max-warnings 0`.
- Traditional Chinese generation and the complete i18n gate passed.
- Chromium and mobile-chrome managed E2E tests passed, including desktop and
mobile geometry/overflow assertions and fresh PR captures.
- Public-doc test suite passed (61 tests), public-doc validation passed (46
pages), specification lint passed, and `git diff --check` passed.
+11
View File
@@ -13,6 +13,17 @@ Kandev includes short-lived chat, reusable AI helpers, dictation, file and edito
2. Use a task session for work that needs files, review, or workflow state.
3. Add utility agents, editors, language servers, or terminals only when their host boundary is acceptable.
## Configure Apprise notifications
To detect Apprise after you install it, open **Settings > Preferences > Notifications** and select
**Rescan Apprise** under **External Providers**. Kandev checks for the `apprise` executable in the
environment of the running Kandev backend. When it is found, **Add Apprise Provider** becomes
available. Rescanning does not save notification drafts or send a notification.
If Kandev does not detect Apprise, check the `PATH` used by the backend process. If you install
Apprise in a directory already in that `PATH`, rescan. If you install it elsewhere or change `PATH`
after Kandev starts, restart Kandev so the running backend receives the new environment, then rescan.
## Quick Chat
Quick Chat is an agent conversation outside the board. Use it for repository orientation, experiments, and disposable questions that do not need workflow state, review gates, dependencies, or a delivery record.
+2
View File
@@ -60,6 +60,7 @@ localization, feature toggles, health, and shared session recovery services.
- [Session MCP Attachment Observability](requirements/mcp-session-observability.md)
- [Mid-Turn Steering](requirements/mid-turn-steering.md)
- [Semantic Notifications](requirements/notifications.md)
- [Apprise rescan](requirements/apprise-rescan.md)
- [Required Persisted Store Parity](requirements/postgres-domain-store-parity.md)
- [Provider Error Recovery](requirements/provider-error-recovery.md)
- [Session Config Reconciliation Across Agent Types](requirements/session-config-cross-agent-reconcile.md)
@@ -78,6 +79,7 @@ localization, feature toggles, health, and shared session recovery services.
- [Agent process exit and stderr drain](system-design/agent-process-exit-drain.md)
- [Agentctl instance stop idempotency](system-design/agentctl-instance-stop.md)
- [Apprise rescan](system-design/apprise-rescan.md)
- [Background Work Liveness](system-design/background-work-liveness.md)
- [Backend restart page recovery](system-design/backend-restart-page-recovery.md)
- [Browser console retention](system-design/browser-console-retention.md)
@@ -0,0 +1,47 @@
---
status: draft
system: platform
created: 2026-09-09
owners:
- kandev
---
# Apprise Rescan Requirements
## Overview
Users can detect Apprise after installing it while Kandev is running.
Platform owns this capability because it owns notification provider availability.
This extends provider discovery; [semantic notifications](notifications.md)
continues to own event selection and delivery.
## Requirements
### REQ-PLATFORM-APPRISE-RESCAN-001: Manual Apprise detection
**Intent:** Refresh Apprise availability from Notifications settings without
reloading the page or losing settings drafts.
#### Acceptance criteria
- **AC-PLATFORM-APPRISE-RESCAN-001.1:** External Providers shall offer a
labeled rescan action whether Apprise is detected or missing.
- **AC-PLATFORM-APPRISE-RESCAN-001.2:** When a rescan completes, the page shall
show the current detection result and update access to Add Apprise Provider.
Detection shall use the environment where the Kandev backend runs.
- **AC-PLATFORM-APPRISE-RESCAN-001.3:** While checking, the action shall show
progress and prevent duplicate requests. After a request error, it shall show
retry feedback and retain the last known availability.
- **AC-PLATFORM-APPRISE-RESCAN-001.4:** Rescanning shall preserve unsaved names,
URLs, event selections, removals, and open forms. It shall neither save settings
nor send notifications. A rescan alone shall not mark settings dirty.
- **AC-PLATFORM-APPRISE-RESCAN-001.5:** Desktop and mobile shall expose the same
action and feedback. It shall support keyboard activation, translated copy,
a touch target at least 44px high, and no document horizontal overflow.
## Out of scope
- Installing Apprise, importing its configuration, or discovering service URLs.
- Searching another machine or changing the running backend's environment.
- Automatic polling, new provider types, or changes to notification delivery.
- General notification loading-error redesign or unrelated settings layout.
@@ -0,0 +1,105 @@
---
status: draft
system: platform
requirements:
- REQ-PLATFORM-APPRISE-RESCAN-001
---
# Apprise Rescan System Design
## Boundaries and evidence
`Controller.ListProviders` already returns freshly evaluated
`apprise_available` from `AppriseProvider.Available`, which calls
`exec.LookPath("apprise")`. Reuse `GET /api/v1/notification-providers` through
`listNotificationProviders({ cache: "no-store" })`. No new endpoint is needed.
`useNotificationProviders` currently loads once. `useNotificationsState`
copies availability into local state inside its draft hydration guard.
That guard can prevent availability updates while provider edits are pending.
Platform owns detection. Existing settings authorization and
[semantic notification rules](../requirements/notifications.md) remain the
boundaries for configuration and delivery.
## Requirement mapping
| Acceptance criteria | Design section |
| --- | --- |
| 001.1, 001.2 | Request and state flow |
| 001.3, 001.4 | Failure and draft preservation |
| 001.5 | Desktop and mobile presentation |
All rows refer to `AC-PLATFORM-APPRISE-RESCAN-*`.
## Request and state flow
Expose an explicit availability refresh from `useNotificationProviders`.
The domain hook owns the request, pending state, and request failure.
Wait for initial provider loading to finish before enabling manual refresh.
Guard duplicate calls synchronously as well as disabling the button.
Consume only `apprise_available` from the manual response. Add a narrow
`setAppriseAvailable` settings-store action so a delayed response cannot replace
provider records, events, or loading state changed by another operation.
Keep the existing initial-load behavior separate.
Read availability directly from the domain store in `useNotificationsState`,
or synchronize it independently of the provider draft guard. Remove obsolete
local availability setters after checking all callers. Preserve the existing
initial unknown-state fallback in this scoped change.
## Failure and draft preservation
The refresh changes availability only. Preserve provider baselines, drafts,
pending removals, name and URL input, and event selections on success or error.
This also avoids overwriting a provider save that finishes during a rescan.
Ignore response provider arrays for manual rescans.
Clear the previous request error when retrying. On failure, retain availability
and show localized inline retry feedback. Always clear pending state.
An unavailable result is a completed check, not a request failure.
Do not hide an already open create form if a rescan reports Apprise missing;
retain its input and keep the existing save behavior authoritative.
The action does not invoke create, update, delete, test-notification, or browser
permission APIs. It creates no persisted settings or schema changes.
## Desktop and mobile presentation
Entry point: `/settings/preferences/notifications`, External Providers.
Use an outline `Button` labeled `Rescan Apprise`, with the existing refresh icon.
Show `Checking...` while pending and a short detected/not-detected result after
completion. Explain visibly that detection runs on the Kandev server.
Keep the empty configured-provider message distinct from binary availability.
The local exemplar is `DesktopNotificationsSection` for refresh placement.
The curated `mobile-fab.tsx` exemplar contributes an explicit touch target and
pressed feedback; this contextual settings action stays inline.
Use a header row on desktop and a wrapping or stacked action below the heading
on phones. The action needs no picker, drawer, or new route because it has no
choices or deep content. Keep the existing settings page as the scroll owner.
Maintain normal desktop button density and apply the 44px minimum only for
phone/coarse-pointer use. No fixed control or additional safe-area container is
needed. Use a semantic button, accessible status/error feedback, and visible
focus styling. Share the hook and action handler across viewports.
Add copy in English, Portuguese, and Simplified Chinese, then generate both
Traditional Chinese catalogs with `pnpm run i18n:zh-hant`.
## Validation and documentation
Hook tests cover detection changes, duplicate prevention, failure/retry, and a
save that updates provider data before a delayed rescan resolves.
Settings tests cover availability updates during dirty drafts and preservation
of open create/edit forms. Desktop and mobile Playwright tests exercise the
button using controlled provider-list responses; mobile also checks geometry.
The existing backend lookup is reused without production Go changes.
Add a short Notifications how-to section to `docs/public/developer-tools.md`
during implementation. Explain server-local detection and that changing PATH
outside the running process can still require restarting Kandev.
No ADR is needed: this reuses the current endpoint, permissions, lookup method,
and configuration ownership.