diff --git a/panel/src/app/(dashboard)/layout.tsx b/panel/src/app/(dashboard)/layout.tsx
index 8b476bb4..222993c5 100644
--- a/panel/src/app/(dashboard)/layout.tsx
+++ b/panel/src/app/(dashboard)/layout.tsx
@@ -4,6 +4,7 @@ import { Header } from "@/components/layout/header";
import { BottomTabBar } from "@/components/layout/bottom-tab-bar";
import { ScrollRestoration } from "@/components/scroll-restoration";
import { RateLimitBanner } from "@/components/rate-limit/rate-limit-banner";
+import { AutoRefreshDriver } from "@/components/providers/auto-refresh-driver";
export default function DashboardLayout({
children,
@@ -14,6 +15,7 @@ export default function DashboardLayout({
// h-dvh (not h-screen/100vh): mobile Safari's dynamic toolbar resizes the
// viewport, and 100vh doesn't track that — dvh does.
+
diff --git a/panel/src/app/(dashboard)/settings/__tests__/page.test.tsx b/panel/src/app/(dashboard)/settings/__tests__/page.test.tsx
index 7890b12f..f698a3bc 100644
--- a/panel/src/app/(dashboard)/settings/__tests__/page.test.tsx
+++ b/panel/src/app/(dashboard)/settings/__tests__/page.test.tsx
@@ -1,28 +1,30 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
-import { render, screen, fireEvent, waitFor } from "@testing-library/react";
-import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
-import type { ReactNode } from "react";
+import { render, screen, fireEvent } from "@testing-library/react";
-const { getAll, update } = vi.hoisted(() => ({
- getAll: vi.fn(async () => ({
- notifications_enabled: "false",
- sound_enabled: "false",
- auto_refresh: "false",
- refresh_interval: "45",
- })),
- update: vi.fn(async () => ({})),
+// The four prefs below are CLIENT-ONLY (never sent to the backend — the
+// server's settings allowlist is transcript_retention_days + feature flags
+// only, see roboco/services/settings.py). This mock stands in for the
+// persisted UI store; mutate its fields per-test to control what the page
+// renders.
+const mockStore = vi.hoisted(() => ({
+ sidebarCollapsed: false,
+ setSidebarCollapsed: vi.fn(),
+ notificationsEnabled: true,
+ setNotificationsEnabled: vi.fn(),
+ soundEnabled: true,
+ setSoundEnabled: vi.fn(),
+ autoRefresh: false,
+ setAutoRefresh: vi.fn(),
+ refreshIntervalSeconds: 30,
+ setRefreshIntervalSeconds: vi.fn(),
}));
-vi.mock("@/lib/api", () => ({ settingsApi: { getAll, update } }));
+vi.mock("@/store", () => ({ useUIStore: () => mockStore }));
vi.mock("next-themes", () => ({
useTheme: () => ({ theme: "dark", setTheme: vi.fn() }),
}));
-vi.mock("@/store", () => ({
- useUIStore: () => ({ sidebarCollapsed: false, setSidebarCollapsed: vi.fn() }),
-}));
-
vi.mock("@/components/settings/transcript-retention-card", () => ({
TranscriptRetentionCard: () => null,
}));
@@ -31,19 +33,8 @@ vi.mock("@/components/settings/feature-flags-card", () => ({
FeatureFlagsCard: () => null,
}));
-vi.mock("sonner", () => ({
- toast: { success: vi.fn(), error: vi.fn() },
-}));
-
import SettingsPage from "../page";
-function withQueryClient(ui: ReactNode) {
- const client = new QueryClient({
- defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
- });
- return {ui};
-}
-
// The Label and Switch/Select are siblings inside a flex row, so the label
// text doesn't associate with the control. Walk to the row to find it.
function controlFor(labelText: RegExp | string, role: string): HTMLElement {
@@ -55,46 +46,74 @@ function controlFor(labelText: RegExp | string, role: string): HTMLElement {
return el as HTMLElement;
}
-describe("SettingsPage — Save persists prefs via settingsApi (H16)", () => {
+function resetStore() {
+ mockStore.sidebarCollapsed = false;
+ mockStore.notificationsEnabled = true;
+ mockStore.soundEnabled = true;
+ mockStore.autoRefresh = false;
+ mockStore.refreshIntervalSeconds = 30;
+ for (const fn of [
+ mockStore.setSidebarCollapsed,
+ mockStore.setNotificationsEnabled,
+ mockStore.setSoundEnabled,
+ mockStore.setAutoRefresh,
+ mockStore.setRefreshIntervalSeconds,
+ ]) {
+ fn.mockReset();
+ }
+}
+
+describe("SettingsPage — client-only prefs (store-driven, no server round trip)", () => {
beforeEach(() => {
- getAll.mockReset();
- update.mockReset();
- getAll.mockResolvedValue({
- notifications_enabled: "false",
- sound_enabled: "false",
- auto_refresh: "false",
- refresh_interval: "45",
- });
- update.mockResolvedValue({});
+ resetStore();
});
- it("initializes the prefs from the server, not the hardcoded defaults", async () => {
- render(withQueryClient());
+ it("has no Save Settings button — every pref is instant-apply", () => {
+ render();
+ expect(
+ screen.queryByRole("button", { name: /save settings/i }),
+ ).not.toBeInTheDocument();
+ });
- await waitFor(() =>
- expect(controlFor("Enable Notifications", "switch")).not.toBeChecked(),
- );
+ it("renders the four prefs from the store", () => {
+ mockStore.notificationsEnabled = false;
+ mockStore.soundEnabled = false;
+ mockStore.autoRefresh = true;
+ mockStore.refreshIntervalSeconds = 60;
+ render();
+
+ expect(controlFor("Enable Notifications", "switch")).not.toBeChecked();
expect(controlFor("Sound Alerts", "switch")).not.toBeChecked();
- expect(controlFor("Auto Refresh", "switch")).not.toBeChecked();
- // refresh_interval "45" overrides the hardcoded "30s" default.
- expect(controlFor("Refresh Interval", "combobox")).not.toHaveTextContent(
- "30s",
+ expect(controlFor("Auto Refresh", "switch")).toBeChecked();
+ expect(controlFor("Refresh Interval", "combobox")).toHaveTextContent(
+ "1m",
);
});
- it("persists all four prefs when Save Settings is clicked", async () => {
- render(withQueryClient());
+ it("toggling Auto Refresh calls setAutoRefresh directly — no edits/save step", () => {
+ render();
+ fireEvent.click(controlFor("Auto Refresh", "switch"));
+ expect(mockStore.setAutoRefresh).toHaveBeenCalledWith(true);
+ });
- await waitFor(() =>
- expect(controlFor("Enable Notifications", "switch")).not.toBeChecked(),
- );
+ it("toggling Enable Notifications calls setNotificationsEnabled directly", () => {
+ render();
+ fireEvent.click(controlFor("Enable Notifications", "switch"));
+ expect(mockStore.setNotificationsEnabled).toHaveBeenCalledWith(false);
+ });
- fireEvent.click(screen.getByRole("button", { name: /save settings/i }));
+ it("Refresh Interval select is disabled while Auto Refresh is off", () => {
+ render();
+ expect(controlFor("Refresh Interval", "combobox")).toBeDisabled();
+ });
- await waitFor(() => expect(update).toHaveBeenCalledTimes(4));
- expect(update).toHaveBeenCalledWith("notifications_enabled", "false");
- expect(update).toHaveBeenCalledWith("sound_enabled", "false");
- expect(update).toHaveBeenCalledWith("auto_refresh", "false");
- expect(update).toHaveBeenCalledWith("refresh_interval", "45");
+ it("Sound Alerts switch stays disabled — and inert — when notifications are off", () => {
+ mockStore.notificationsEnabled = false;
+ render();
+ const soundSwitch = controlFor("Sound Alerts", "switch");
+ expect(soundSwitch).toBeDisabled();
+
+ fireEvent.click(soundSwitch);
+ expect(mockStore.setSoundEnabled).not.toHaveBeenCalled();
});
});
diff --git a/panel/src/app/(dashboard)/settings/page.tsx b/panel/src/app/(dashboard)/settings/page.tsx
index 8cb59ca2..a8b47341 100644
--- a/panel/src/app/(dashboard)/settings/page.tsx
+++ b/panel/src/app/(dashboard)/settings/page.tsx
@@ -1,10 +1,7 @@
"use client";
-import { useState } from "react";
import { useTheme } from "next-themes";
-import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useUIStore } from "@/store";
-import { settingsApi } from "@/lib/api";
import {
Card,
CardContent,
@@ -14,7 +11,6 @@ import {
} from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
-import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Select,
@@ -24,85 +20,25 @@ import {
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
-import { Settings, Palette, Bell, Server, User, Save } from "lucide-react";
-import { toast } from "sonner";
+import { Settings, Palette, Bell, Server, User } from "lucide-react";
import { API_URL, WS_URL } from "@/lib/constants";
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
import { FeatureFlagsCard } from "@/components/settings/feature-flags-card";
-// Settings keys persisted server-side (string values: "true"/"false" or a number).
-const KEYS = {
- notifications: "notifications_enabled",
- sound: "sound_enabled",
- autoRefresh: "auto_refresh",
- refreshInterval: "refresh_interval",
-} as const;
-
export default function SettingsPage() {
const { theme, setTheme } = useTheme();
- const { sidebarCollapsed, setSidebarCollapsed } = useUIStore();
- const queryClient = useQueryClient();
-
- const { data: settings } = useQuery({
- queryKey: ["settings"],
- queryFn: settingsApi.getAll,
- });
-
- // `edits` holds the user's in-progress changes; an unset field means "show
- // the server value" (or the hardcoded default before the query loads).
- // Deriving the displayed value avoids syncing query state into local state
- // via an effect (react-hooks/set-state-in-effect).
- const [edits, setEdits] = useState<{
- notifications?: boolean;
- sound?: boolean;
- autoRefresh?: boolean;
- refreshInterval?: string;
- }>({});
-
- const notificationsEnabled =
- edits.notifications ??
- (settings?.[KEYS.notifications] === undefined
- ? true
- : settings[KEYS.notifications] === "true");
- const soundEnabled =
- edits.sound ??
- (settings?.[KEYS.sound] === undefined
- ? true
- : settings[KEYS.sound] === "true");
- const autoRefresh =
- edits.autoRefresh ??
- (settings?.[KEYS.autoRefresh] === undefined
- ? true
- : settings[KEYS.autoRefresh] === "true");
- const refreshInterval =
- edits.refreshInterval ??
- (settings?.[KEYS.refreshInterval] === undefined
- ? "30"
- : settings[KEYS.refreshInterval]);
-
- const saveMutation = useMutation({
- mutationFn: async () => {
- await settingsApi.update(
- KEYS.notifications,
- String(notificationsEnabled),
- );
- await settingsApi.update(KEYS.sound, String(soundEnabled));
- await settingsApi.update(KEYS.autoRefresh, String(autoRefresh));
- await settingsApi.update(KEYS.refreshInterval, refreshInterval);
- },
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["settings"] });
- setEdits({}); // re-sync to the freshly-saved server values
- toast.success("Settings saved successfully");
- },
- onError: (error) => {
- toast.error(
- `Failed to save: ${error instanceof Error ? error.message : "Unknown error"}`,
- );
- },
- });
-
- const handleSave = () => saveMutation.mutate();
+ const {
+ sidebarCollapsed,
+ setSidebarCollapsed,
+ notificationsEnabled,
+ setNotificationsEnabled,
+ soundEnabled,
+ setSoundEnabled,
+ autoRefresh,
+ setAutoRefresh,
+ refreshIntervalSeconds,
+ setRefreshIntervalSeconds,
+ } = useUIStore();
return (
@@ -193,7 +129,8 @@ export default function SettingsPage() {
- {/* Data & Refresh */}
+ {/* Data & Refresh — client-only prefs, instant-apply (same idiom as
+ Theme/Sidebar above); never sent to the backend. */}
@@ -207,15 +144,10 @@ export default function SettingsPage() {
- Automatically refresh data periodically
+ Periodically re-fetch the current page's data
@@ -322,14 +250,6 @@ export default function SettingsPage() {
persisted server-side, applied on next restart). The X (Twitter)
credentials form nests as a collapsible under the X-engine flag. */}
-
- {/* Save Button */}
-
-
-
);
}
diff --git a/panel/src/components/layout/header.tsx b/panel/src/components/layout/header.tsx
index 08b59a44..8d4ec761 100644
--- a/panel/src/components/layout/header.tsx
+++ b/panel/src/components/layout/header.tsx
@@ -11,6 +11,7 @@ import {
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { NotificationBell } from "@/components/notifications/notification-bell";
+import { NotificationAlerts } from "@/components/notifications/notification-alerts";
import { ConnectionStatus } from "./connection-status";
import { MobileSidebar } from "./mobile-sidebar";
import {
@@ -102,6 +103,7 @@ export function Header() {
{/* Notifications with WebSocket */}
+
{/* User */}
diff --git a/panel/src/components/notifications/__tests__/notification-alerts.test.tsx b/panel/src/components/notifications/__tests__/notification-alerts.test.tsx
new file mode 100644
index 00000000..254805e8
--- /dev/null
+++ b/panel/src/components/notifications/__tests__/notification-alerts.test.tsx
@@ -0,0 +1,148 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render } from "@testing-library/react";
+import { toast } from "sonner";
+import { NotificationAlerts } from "../notification-alerts";
+import type { NotificationMessage } from "@/hooks/use-websocket";
+
+// Mutable stand-in for the WS notification stream — reassign (never mutate in
+// place) `list` between renders so the effect's dependency actually changes.
+const mockStream = vi.hoisted(() => ({
+ list: [] as NotificationMessage[],
+}));
+
+vi.mock("@/hooks/use-websocket", () => ({
+ useNotificationStream: () => ({ notifications: mockStream.list }),
+}));
+
+// Non-reactive stand-in for the persisted UI store (zustand selector form).
+const mockUiStore = vi.hoisted(() => ({
+ notificationsEnabled: true,
+ soundEnabled: true,
+}));
+
+vi.mock("@/store", () => ({
+ useUIStore: (selector: (s: typeof mockUiStore) => unknown) =>
+ selector(mockUiStore),
+}));
+
+vi.mock("sonner", () => ({ toast: vi.fn() }));
+
+function notification(overrides: Partial) {
+ return { type: "notification", ...overrides } as NotificationMessage;
+}
+
+// AudioContext doesn't exist in jsdom; stub a constructible fake so playChime
+// can run its real path instead of hitting the "no AudioContext" early return.
+function stubAudioContext() {
+ const ctor = vi.fn(function FakeAudioContext() {
+ return {
+ currentTime: 0,
+ createOscillator: () => ({
+ type: "sine",
+ frequency: { value: 0 },
+ connect: (dest: unknown) => dest,
+ start: vi.fn(),
+ stop: vi.fn(),
+ onended: null,
+ }),
+ createGain: () => ({
+ gain: { value: 0 },
+ connect: (dest: unknown) => dest,
+ }),
+ close: vi.fn(),
+ };
+ });
+ vi.stubGlobal("AudioContext", ctor);
+ return ctor;
+}
+
+describe("NotificationAlerts", () => {
+ beforeEach(() => {
+ mockStream.list = [];
+ mockUiStore.notificationsEnabled = true;
+ mockUiStore.soundEnabled = true;
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ });
+
+ it("ignores whatever the stream already has on mount (no backlog toast)", () => {
+ mockStream.list = [
+ notification({ notification_id: "1", subject: "Old one" }),
+ ];
+ render();
+ expect(toast).not.toHaveBeenCalled();
+ });
+
+ it("toasts a newly-arrived notification while enabled", () => {
+ const { rerender } = render();
+ expect(toast).not.toHaveBeenCalled();
+
+ mockStream.list = [
+ ...mockStream.list,
+ notification({ notification_id: "2", subject: "New task", priority: "high" }),
+ ];
+ rerender();
+
+ expect(toast).toHaveBeenCalledTimes(1);
+ expect(toast).toHaveBeenCalledWith(
+ "New task",
+ expect.objectContaining({ description: expect.stringContaining("high") }),
+ );
+ });
+
+ it("does not toast when notifications are disabled", () => {
+ mockUiStore.notificationsEnabled = false;
+ const { rerender } = render();
+
+ mockStream.list = [
+ ...mockStream.list,
+ notification({ notification_id: "3", subject: "Silenced" }),
+ ];
+ rerender();
+
+ expect(toast).not.toHaveBeenCalled();
+ });
+
+ it("plays a chime via Web Audio when sound is enabled", () => {
+ const ctor = stubAudioContext();
+ const { rerender } = render();
+
+ mockStream.list = [
+ ...mockStream.list,
+ notification({ notification_id: "4", subject: "Ping" }),
+ ];
+ rerender();
+
+ expect(ctor).toHaveBeenCalledTimes(1);
+ });
+
+ it("skips the chime when sound is disabled, but still toasts", () => {
+ const ctor = stubAudioContext();
+ mockUiStore.soundEnabled = false;
+ const { rerender } = render();
+
+ mockStream.list = [
+ ...mockStream.list,
+ notification({ notification_id: "5", subject: "Quiet" }),
+ ];
+ rerender();
+
+ expect(toast).toHaveBeenCalledTimes(1);
+ expect(ctor).not.toHaveBeenCalled();
+ });
+
+ it("never crashes when AudioContext is unavailable (autoplay-blocked browsers)", () => {
+ vi.stubGlobal("AudioContext", undefined);
+ const { rerender } = render();
+
+ mockStream.list = [
+ ...mockStream.list,
+ notification({ notification_id: "6", subject: "Still fine" }),
+ ];
+ expect(() => rerender()).not.toThrow();
+ expect(toast).toHaveBeenCalledTimes(1);
+ });
+});
diff --git a/panel/src/components/notifications/notification-alerts.tsx b/panel/src/components/notifications/notification-alerts.tsx
new file mode 100644
index 00000000..7c4f6784
--- /dev/null
+++ b/panel/src/components/notifications/notification-alerts.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { toast } from "sonner";
+import { useNotificationStream } from "@/hooks/use-websocket";
+import { useUIStore } from "@/store";
+
+/** ~120ms, low-volume beep via Web Audio — no audio asset. Never throws. */
+function playChime() {
+ try {
+ const AudioCtx =
+ window.AudioContext ??
+ (window as unknown as { webkitAudioContext?: typeof AudioContext })
+ .webkitAudioContext;
+ if (!AudioCtx) return;
+ const ctx = new AudioCtx();
+ const osc = ctx.createOscillator();
+ const gain = ctx.createGain();
+ osc.type = "sine";
+ osc.frequency.value = 880;
+ gain.gain.value = 0.05;
+ osc.connect(gain).connect(ctx.destination);
+ osc.start();
+ osc.stop(ctx.currentTime + 0.12);
+ osc.onended = () => void ctx.close();
+ } catch {
+ // Autoplay/permission blocks are expected on some browsers — no-op.
+ }
+}
+
+/**
+ * Watches the live notification stream and toasts + optionally chimes for
+ * newly-arrived entries. Mount exactly once (next to the bell). Renders
+ * nothing.
+ */
+export function NotificationAlerts() {
+ const { notifications } = useNotificationStream();
+ const notificationsEnabled = useUIStore((s) => s.notificationsEnabled);
+ const soundEnabled = useUIStore((s) => s.soundEnabled);
+
+ // null = not yet initialized; primes on first render so a page load never
+ // toasts a backlog the stream replays on connect.
+ const seenCountRef = useRef(null);
+
+ useEffect(() => {
+ if (seenCountRef.current === null) {
+ seenCountRef.current = notifications.length;
+ return;
+ }
+ const newOnes = notifications.slice(seenCountRef.current);
+ seenCountRef.current = notifications.length;
+ if (newOnes.length === 0 || !notificationsEnabled) return;
+
+ for (const n of newOnes) {
+ toast(n.subject ?? "New notification", {
+ description: n.priority ? `Priority: ${n.priority}` : undefined,
+ });
+ }
+ if (soundEnabled) playChime();
+ }, [notifications, notificationsEnabled, soundEnabled]);
+
+ return null;
+}
diff --git a/panel/src/components/providers/__tests__/auto-refresh-driver.test.tsx b/panel/src/components/providers/__tests__/auto-refresh-driver.test.tsx
new file mode 100644
index 00000000..1a42d20a
--- /dev/null
+++ b/panel/src/components/providers/__tests__/auto-refresh-driver.test.tsx
@@ -0,0 +1,119 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { render, act } from "@testing-library/react";
+import { useState } from "react";
+import { AutoRefreshDriver } from "../auto-refresh-driver";
+import { PageRefreshProvider } from "../page-refresh-provider";
+import { usePageRefresh } from "@/hooks";
+
+// Non-reactive stand-in for the persisted UI store: AutoRefreshDriver reads it
+// via the zustand selector form (`useUIStore((s) => s.foo)`), so the mock must
+// accept and apply a selector. Mutate the fields directly and re-render to
+// simulate a store update (matches the settings-page test idiom).
+const mockStore = vi.hoisted(() => ({
+ autoRefresh: false,
+ refreshIntervalSeconds: 10,
+}));
+
+vi.mock("@/store", () => ({
+ useUIStore: (selector: (s: typeof mockStore) => unknown) =>
+ selector(mockStore),
+}));
+
+function Registrator({ callback }: { callback: () => void | Promise }) {
+ const { register } = usePageRefresh();
+ const [registered, setRegistered] = useState(false);
+ if (!registered) {
+ register(callback);
+ setRegistered(true);
+ }
+ return null;
+}
+
+function Harness({
+ callback,
+ mountRegistrator = true,
+}: {
+ callback: () => void | Promise;
+ mountRegistrator?: boolean;
+}) {
+ return (
+
+ {mountRegistrator && }
+
+
+ );
+}
+
+describe("AutoRefreshDriver", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ mockStore.autoRefresh = false;
+ mockStore.refreshIntervalSeconds = 10;
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("arms no interval when disabled (nothing registered), even if the pref is on", () => {
+ mockStore.autoRefresh = true;
+ render();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("arms no interval when Auto Refresh is off, even with a callback registered", () => {
+ render();
+ expect(vi.getTimerCount()).toBe(0);
+ });
+
+ it("fires refresh at the configured interval when enabled + registered", async () => {
+ mockStore.autoRefresh = true;
+ const callback = vi.fn();
+ render();
+
+ // Async act so the refresh() promise chain (setLoading true -> ... ->
+ // false) drains between ticks — otherwise the second tick's
+ // loadingRef.current read races the still-pending microtask.
+ await act(async () => {
+ vi.advanceTimersByTime(10_000);
+ });
+ expect(callback).toHaveBeenCalledTimes(1);
+
+ await act(async () => {
+ vi.advanceTimersByTime(10_000);
+ });
+ expect(callback).toHaveBeenCalledTimes(2);
+ });
+
+ it("respects a changed interval", () => {
+ mockStore.autoRefresh = true;
+ mockStore.refreshIntervalSeconds = 5;
+ const callback = vi.fn();
+ render();
+
+ act(() => {
+ vi.advanceTimersByTime(5_000);
+ });
+ expect(callback).toHaveBeenCalledTimes(1);
+ });
+
+ it("stops ticking once the pref turns off (cleans up the interval)", () => {
+ mockStore.autoRefresh = true;
+ const callback = vi.fn();
+ const { rerender } = render();
+
+ act(() => {
+ vi.advanceTimersByTime(10_000);
+ });
+ expect(callback).toHaveBeenCalledTimes(1);
+
+ mockStore.autoRefresh = false;
+ rerender();
+ expect(vi.getTimerCount()).toBe(0);
+
+ act(() => {
+ vi.advanceTimersByTime(10_000);
+ });
+ expect(callback).toHaveBeenCalledTimes(1); // unchanged
+ });
+});
diff --git a/panel/src/components/providers/auto-refresh-driver.tsx b/panel/src/components/providers/auto-refresh-driver.tsx
new file mode 100644
index 00000000..2712a4d0
--- /dev/null
+++ b/panel/src/components/providers/auto-refresh-driver.tsx
@@ -0,0 +1,32 @@
+"use client";
+
+import { useEffect, useRef } from "react";
+import { usePageRefresh } from "@/hooks";
+import { useUIStore } from "@/store";
+
+/**
+ * Ticks the page-refresh registry on an interval when the user's Auto Refresh
+ * preference is on. Must render inside `PageRefreshProvider`. Renders nothing.
+ */
+export function AutoRefreshDriver() {
+ const { refresh, disabled, loading } = usePageRefresh();
+ const autoRefresh = useUIStore((s) => s.autoRefresh);
+ const refreshIntervalSeconds = useUIStore((s) => s.refreshIntervalSeconds);
+
+ // Read via ref inside the tick so an in-flight refresh only skips that one
+ // tick, instead of tearing down/re-arming the interval on every loading flip.
+ const loadingRef = useRef(loading);
+ useEffect(() => {
+ loadingRef.current = loading;
+ }, [loading]);
+
+ useEffect(() => {
+ if (!autoRefresh || disabled) return;
+ const id = setInterval(() => {
+ if (!loadingRef.current) void refresh();
+ }, refreshIntervalSeconds * 1000);
+ return () => clearInterval(id);
+ }, [autoRefresh, disabled, refreshIntervalSeconds, refresh]);
+
+ return null;
+}
diff --git a/panel/src/store/ui-store.ts b/panel/src/store/ui-store.ts
index 38cd8794..8b8283cb 100644
--- a/panel/src/store/ui-store.ts
+++ b/panel/src/store/ui-store.ts
@@ -17,12 +17,24 @@ interface UIState {
// design doc §1) — same persisted-preference idiom as sidebar/theme.
a2aContextOpen: boolean;
+ // Client-only Settings-page prefs (never sent to the backend — the
+ // server's settings allowlist is transcript_retention_days + feature
+ // flags only). Same persisted-preference idiom as sidebar/theme.
+ notificationsEnabled: boolean;
+ soundEnabled: boolean;
+ autoRefresh: boolean;
+ refreshIntervalSeconds: number;
+
// Actions
toggleSidebar: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
setTheme: (theme: "light" | "dark" | "system") => void;
setCurrentTeam: (team: Team | null) => void;
toggleA2AContext: () => void;
+ setNotificationsEnabled: (enabled: boolean) => void;
+ setSoundEnabled: (enabled: boolean) => void;
+ setAutoRefresh: (enabled: boolean) => void;
+ setRefreshIntervalSeconds: (seconds: number) => void;
}
export const useUIStore = create()(
@@ -33,6 +45,10 @@ export const useUIStore = create()(
theme: "system",
currentTeam: null,
a2aContextOpen: true,
+ notificationsEnabled: true,
+ soundEnabled: true,
+ autoRefresh: false, // default-off: never start a background poller unasked
+ refreshIntervalSeconds: 30,
toggleSidebar: () =>
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
@@ -41,6 +57,12 @@ export const useUIStore = create()(
setCurrentTeam: (team) => set({ currentTeam: team }),
toggleA2AContext: () =>
set((state) => ({ a2aContextOpen: !state.a2aContextOpen })),
+ setNotificationsEnabled: (enabled) =>
+ set({ notificationsEnabled: enabled }),
+ setSoundEnabled: (enabled) => set({ soundEnabled: enabled }),
+ setAutoRefresh: (enabled) => set({ autoRefresh: enabled }),
+ setRefreshIntervalSeconds: (seconds) =>
+ set({ refreshIntervalSeconds: seconds }),
}),
{
name: "roboco-ui-storage",
@@ -49,6 +71,10 @@ export const useUIStore = create()(
theme: state.theme,
currentTeam: state.currentTeam,
a2aContextOpen: state.a2aContextOpen,
+ notificationsEnabled: state.notificationsEnabled,
+ soundEnabled: state.soundEnabled,
+ autoRefresh: state.autoRefresh,
+ refreshIntervalSeconds: state.refreshIntervalSeconds,
}),
},
),