mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
fix(panel): settings preferences become real client prefs — no more 422 save, no more theater toggles (#487)
The Settings page PUT four keys (notifications_enabled, sound_enabled, auto_refresh, refresh_interval) the backend's settings allowlist never accepted — Save died on the first 422 and had never persisted these cards. Worse, nothing consumed the prefs anywhere: no auto-refresh timer, no notification toast, no sound system existed. Pure theater. - the four prefs move into the persisted UI store (client-only, same idiom as theme/sidebar) and the cards apply instantly; the dead server plumbing and the global Save button are gone — the backend allowlist stays strict and untouched - AutoRefreshDriver (new): when Auto Refresh is on, ticks the page-refresh registry every N seconds — skips while nothing is registered or a refresh is in flight; default-off so no background poller starts unasked - NotificationAlerts (new): toasts each newly-arrived WS notification (subject + priority) when notifications are enabled, with an optional ~120ms Web-Audio chime — initial backlog on connect never toasts, one chime per batch, autoplay blocks never throw - tests: settings page rewritten store-driven; fake-timer coverage for the driver; stream/store/sonner/AudioContext-mocked coverage for alerts Co-authored-by: Renn F <rennf93@users.noreply.github.com>
This commit is contained in:
@@ -4,6 +4,7 @@ import { Header } from "@/components/layout/header";
|
|||||||
import { BottomTabBar } from "@/components/layout/bottom-tab-bar";
|
import { BottomTabBar } from "@/components/layout/bottom-tab-bar";
|
||||||
import { ScrollRestoration } from "@/components/scroll-restoration";
|
import { ScrollRestoration } from "@/components/scroll-restoration";
|
||||||
import { RateLimitBanner } from "@/components/rate-limit/rate-limit-banner";
|
import { RateLimitBanner } from "@/components/rate-limit/rate-limit-banner";
|
||||||
|
import { AutoRefreshDriver } from "@/components/providers/auto-refresh-driver";
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
children,
|
children,
|
||||||
@@ -14,6 +15,7 @@ export default function DashboardLayout({
|
|||||||
// h-dvh (not h-screen/100vh): mobile Safari's dynamic toolbar resizes the
|
// h-dvh (not h-screen/100vh): mobile Safari's dynamic toolbar resizes the
|
||||||
// viewport, and 100vh doesn't track that — dvh does.
|
// viewport, and 100vh doesn't track that — dvh does.
|
||||||
<div className="flex h-dvh overflow-hidden">
|
<div className="flex h-dvh overflow-hidden">
|
||||||
|
<AutoRefreshDriver />
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
<div className="flex flex-1 flex-col overflow-hidden">
|
<div className="flex flex-1 flex-col overflow-hidden">
|
||||||
<Header />
|
<Header />
|
||||||
|
|||||||
@@ -1,28 +1,30 @@
|
|||||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
import { render, screen, fireEvent, waitFor } from "@testing-library/react";
|
import { render, screen, fireEvent } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
||||||
import type { ReactNode } from "react";
|
|
||||||
|
|
||||||
const { getAll, update } = vi.hoisted(() => ({
|
// The four prefs below are CLIENT-ONLY (never sent to the backend — the
|
||||||
getAll: vi.fn(async () => ({
|
// server's settings allowlist is transcript_retention_days + feature flags
|
||||||
notifications_enabled: "false",
|
// only, see roboco/services/settings.py). This mock stands in for the
|
||||||
sound_enabled: "false",
|
// persisted UI store; mutate its fields per-test to control what the page
|
||||||
auto_refresh: "false",
|
// renders.
|
||||||
refresh_interval: "45",
|
const mockStore = vi.hoisted(() => ({
|
||||||
})),
|
sidebarCollapsed: false,
|
||||||
update: vi.fn(async () => ({})),
|
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", () => ({
|
vi.mock("next-themes", () => ({
|
||||||
useTheme: () => ({ theme: "dark", setTheme: vi.fn() }),
|
useTheme: () => ({ theme: "dark", setTheme: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("@/store", () => ({
|
|
||||||
useUIStore: () => ({ sidebarCollapsed: false, setSidebarCollapsed: vi.fn() }),
|
|
||||||
}));
|
|
||||||
|
|
||||||
vi.mock("@/components/settings/transcript-retention-card", () => ({
|
vi.mock("@/components/settings/transcript-retention-card", () => ({
|
||||||
TranscriptRetentionCard: () => null,
|
TranscriptRetentionCard: () => null,
|
||||||
}));
|
}));
|
||||||
@@ -31,19 +33,8 @@ vi.mock("@/components/settings/feature-flags-card", () => ({
|
|||||||
FeatureFlagsCard: () => null,
|
FeatureFlagsCard: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("sonner", () => ({
|
|
||||||
toast: { success: vi.fn(), error: vi.fn() },
|
|
||||||
}));
|
|
||||||
|
|
||||||
import SettingsPage from "../page";
|
import SettingsPage from "../page";
|
||||||
|
|
||||||
function withQueryClient(ui: ReactNode) {
|
|
||||||
const client = new QueryClient({
|
|
||||||
defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
|
|
||||||
});
|
|
||||||
return <QueryClientProvider client={client}>{ui}</QueryClientProvider>;
|
|
||||||
}
|
|
||||||
|
|
||||||
// The Label and Switch/Select are siblings inside a flex row, so the label
|
// 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.
|
// text doesn't associate with the control. Walk to the row to find it.
|
||||||
function controlFor(labelText: RegExp | string, role: string): HTMLElement {
|
function controlFor(labelText: RegExp | string, role: string): HTMLElement {
|
||||||
@@ -55,46 +46,74 @@ function controlFor(labelText: RegExp | string, role: string): HTMLElement {
|
|||||||
return el as 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(() => {
|
beforeEach(() => {
|
||||||
getAll.mockReset();
|
resetStore();
|
||||||
update.mockReset();
|
|
||||||
getAll.mockResolvedValue({
|
|
||||||
notifications_enabled: "false",
|
|
||||||
sound_enabled: "false",
|
|
||||||
auto_refresh: "false",
|
|
||||||
refresh_interval: "45",
|
|
||||||
});
|
|
||||||
update.mockResolvedValue({});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("initializes the prefs from the server, not the hardcoded defaults", async () => {
|
it("has no Save Settings button — every pref is instant-apply", () => {
|
||||||
render(withQueryClient(<SettingsPage />));
|
render(<SettingsPage />);
|
||||||
|
expect(
|
||||||
|
screen.queryByRole("button", { name: /save settings/i }),
|
||||||
|
).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() =>
|
it("renders the four prefs from the store", () => {
|
||||||
expect(controlFor("Enable Notifications", "switch")).not.toBeChecked(),
|
mockStore.notificationsEnabled = false;
|
||||||
);
|
mockStore.soundEnabled = false;
|
||||||
|
mockStore.autoRefresh = true;
|
||||||
|
mockStore.refreshIntervalSeconds = 60;
|
||||||
|
render(<SettingsPage />);
|
||||||
|
|
||||||
|
expect(controlFor("Enable Notifications", "switch")).not.toBeChecked();
|
||||||
expect(controlFor("Sound Alerts", "switch")).not.toBeChecked();
|
expect(controlFor("Sound Alerts", "switch")).not.toBeChecked();
|
||||||
expect(controlFor("Auto Refresh", "switch")).not.toBeChecked();
|
expect(controlFor("Auto Refresh", "switch")).toBeChecked();
|
||||||
// refresh_interval "45" overrides the hardcoded "30s" default.
|
expect(controlFor("Refresh Interval", "combobox")).toHaveTextContent(
|
||||||
expect(controlFor("Refresh Interval", "combobox")).not.toHaveTextContent(
|
"1m",
|
||||||
"30s",
|
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("persists all four prefs when Save Settings is clicked", async () => {
|
it("toggling Auto Refresh calls setAutoRefresh directly — no edits/save step", () => {
|
||||||
render(withQueryClient(<SettingsPage />));
|
render(<SettingsPage />);
|
||||||
|
fireEvent.click(controlFor("Auto Refresh", "switch"));
|
||||||
|
expect(mockStore.setAutoRefresh).toHaveBeenCalledWith(true);
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() =>
|
it("toggling Enable Notifications calls setNotificationsEnabled directly", () => {
|
||||||
expect(controlFor("Enable Notifications", "switch")).not.toBeChecked(),
|
render(<SettingsPage />);
|
||||||
);
|
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(<SettingsPage />);
|
||||||
|
expect(controlFor("Refresh Interval", "combobox")).toBeDisabled();
|
||||||
|
});
|
||||||
|
|
||||||
await waitFor(() => expect(update).toHaveBeenCalledTimes(4));
|
it("Sound Alerts switch stays disabled — and inert — when notifications are off", () => {
|
||||||
expect(update).toHaveBeenCalledWith("notifications_enabled", "false");
|
mockStore.notificationsEnabled = false;
|
||||||
expect(update).toHaveBeenCalledWith("sound_enabled", "false");
|
render(<SettingsPage />);
|
||||||
expect(update).toHaveBeenCalledWith("auto_refresh", "false");
|
const soundSwitch = controlFor("Sound Alerts", "switch");
|
||||||
expect(update).toHaveBeenCalledWith("refresh_interval", "45");
|
expect(soundSwitch).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.click(soundSwitch);
|
||||||
|
expect(mockStore.setSoundEnabled).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,10 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
|
||||||
import { useTheme } from "next-themes";
|
import { useTheme } from "next-themes";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { useUIStore } from "@/store";
|
import { useUIStore } from "@/store";
|
||||||
import { settingsApi } from "@/lib/api";
|
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
CardContent,
|
CardContent,
|
||||||
@@ -14,7 +11,6 @@ import {
|
|||||||
} from "@/components/ui/card";
|
} from "@/components/ui/card";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Switch } from "@/components/ui/switch";
|
import { Switch } from "@/components/ui/switch";
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -24,85 +20,25 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Settings, Palette, Bell, Server, User, Save } from "lucide-react";
|
import { Settings, Palette, Bell, Server, User } from "lucide-react";
|
||||||
import { toast } from "sonner";
|
|
||||||
import { API_URL, WS_URL } from "@/lib/constants";
|
import { API_URL, WS_URL } from "@/lib/constants";
|
||||||
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
|
import { TranscriptRetentionCard } from "@/components/settings/transcript-retention-card";
|
||||||
import { FeatureFlagsCard } from "@/components/settings/feature-flags-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() {
|
export default function SettingsPage() {
|
||||||
const { theme, setTheme } = useTheme();
|
const { theme, setTheme } = useTheme();
|
||||||
const { sidebarCollapsed, setSidebarCollapsed } = useUIStore();
|
const {
|
||||||
const queryClient = useQueryClient();
|
sidebarCollapsed,
|
||||||
|
setSidebarCollapsed,
|
||||||
const { data: settings } = useQuery({
|
notificationsEnabled,
|
||||||
queryKey: ["settings"],
|
setNotificationsEnabled,
|
||||||
queryFn: settingsApi.getAll,
|
soundEnabled,
|
||||||
});
|
setSoundEnabled,
|
||||||
|
autoRefresh,
|
||||||
// `edits` holds the user's in-progress changes; an unset field means "show
|
setAutoRefresh,
|
||||||
// the server value" (or the hardcoded default before the query loads).
|
refreshIntervalSeconds,
|
||||||
// Deriving the displayed value avoids syncing query state into local state
|
setRefreshIntervalSeconds,
|
||||||
// via an effect (react-hooks/set-state-in-effect).
|
} = useUIStore();
|
||||||
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();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
@@ -193,7 +129,8 @@ export default function SettingsPage() {
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* Data & Refresh */}
|
{/* Data & Refresh — client-only prefs, instant-apply (same idiom as
|
||||||
|
Theme/Sidebar above); never sent to the backend. */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -207,15 +144,10 @@ export default function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Label>Auto Refresh</Label>
|
<Label>Auto Refresh</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Automatically refresh data periodically
|
Periodically re-fetch the current page's data
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch checked={autoRefresh} onCheckedChange={setAutoRefresh} />
|
||||||
checked={autoRefresh}
|
|
||||||
onCheckedChange={(v) =>
|
|
||||||
setEdits((e) => ({ ...e, autoRefresh: v }))
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center justify-between">
|
||||||
@@ -226,10 +158,8 @@ export default function SettingsPage() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Select
|
<Select
|
||||||
value={refreshInterval}
|
value={String(refreshIntervalSeconds)}
|
||||||
onValueChange={(v) =>
|
onValueChange={(v) => setRefreshIntervalSeconds(Number(v))}
|
||||||
setEdits((e) => ({ ...e, refreshInterval: v }))
|
|
||||||
}
|
|
||||||
disabled={!autoRefresh}
|
disabled={!autoRefresh}
|
||||||
>
|
>
|
||||||
<SelectTrigger className="w-auto min-w-20">
|
<SelectTrigger className="w-auto min-w-20">
|
||||||
@@ -249,7 +179,7 @@ export default function SettingsPage() {
|
|||||||
{/* Transcript Retention (panel-tunable; persisted server-side) */}
|
{/* Transcript Retention (panel-tunable; persisted server-side) */}
|
||||||
<TranscriptRetentionCard />
|
<TranscriptRetentionCard />
|
||||||
|
|
||||||
{/* Notifications */}
|
{/* Notifications — client-only prefs, instant-apply. */}
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="flex items-center gap-2">
|
<CardTitle className="flex items-center gap-2">
|
||||||
@@ -263,14 +193,12 @@ export default function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Label>Enable Notifications</Label>
|
<Label>Enable Notifications</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Receive real-time notifications from agents
|
Toast + bell for incoming agent notifications
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={notificationsEnabled}
|
checked={notificationsEnabled}
|
||||||
onCheckedChange={(v) =>
|
onCheckedChange={setNotificationsEnabled}
|
||||||
setEdits((e) => ({ ...e, notifications: v }))
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<Separator />
|
<Separator />
|
||||||
@@ -278,12 +206,12 @@ export default function SettingsPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Label>Sound Alerts</Label>
|
<Label>Sound Alerts</Label>
|
||||||
<p className="text-sm text-muted-foreground">
|
<p className="text-sm text-muted-foreground">
|
||||||
Play sound for important notifications
|
Chime on new notifications
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<Switch
|
<Switch
|
||||||
checked={soundEnabled}
|
checked={soundEnabled}
|
||||||
onCheckedChange={(v) => setEdits((e) => ({ ...e, sound: v }))}
|
onCheckedChange={setSoundEnabled}
|
||||||
disabled={!notificationsEnabled}
|
disabled={!notificationsEnabled}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -322,14 +250,6 @@ export default function SettingsPage() {
|
|||||||
persisted server-side, applied on next restart). The X (Twitter)
|
persisted server-side, applied on next restart). The X (Twitter)
|
||||||
credentials form nests as a collapsible under the X-engine flag. */}
|
credentials form nests as a collapsible under the X-engine flag. */}
|
||||||
<FeatureFlagsCard />
|
<FeatureFlagsCard />
|
||||||
|
|
||||||
{/* Save Button */}
|
|
||||||
<div className="flex justify-end">
|
|
||||||
<Button onClick={handleSave} disabled={saveMutation.isPending}>
|
|
||||||
<Save className="h-4 w-4 mr-2" />
|
|
||||||
{saveMutation.isPending ? "Saving..." : "Save Settings"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from "@/components/ui/dropdown-menu";
|
} from "@/components/ui/dropdown-menu";
|
||||||
import { NotificationBell } from "@/components/notifications/notification-bell";
|
import { NotificationBell } from "@/components/notifications/notification-bell";
|
||||||
|
import { NotificationAlerts } from "@/components/notifications/notification-alerts";
|
||||||
import { ConnectionStatus } from "./connection-status";
|
import { ConnectionStatus } from "./connection-status";
|
||||||
import { MobileSidebar } from "./mobile-sidebar";
|
import { MobileSidebar } from "./mobile-sidebar";
|
||||||
import {
|
import {
|
||||||
@@ -102,6 +103,7 @@ export function Header() {
|
|||||||
|
|
||||||
{/* Notifications with WebSocket */}
|
{/* Notifications with WebSocket */}
|
||||||
<NotificationBell />
|
<NotificationBell />
|
||||||
|
<NotificationAlerts />
|
||||||
|
|
||||||
{/* User */}
|
{/* User */}
|
||||||
<div className="flex items-center gap-2 ml-2 pl-4 border-l">
|
<div className="flex items-center gap-2 ml-2 pl-4 border-l">
|
||||||
|
|||||||
@@ -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<NotificationMessage>) {
|
||||||
|
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(<NotificationAlerts />);
|
||||||
|
expect(toast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("toasts a newly-arrived notification while enabled", () => {
|
||||||
|
const { rerender } = render(<NotificationAlerts />);
|
||||||
|
expect(toast).not.toHaveBeenCalled();
|
||||||
|
|
||||||
|
mockStream.list = [
|
||||||
|
...mockStream.list,
|
||||||
|
notification({ notification_id: "2", subject: "New task", priority: "high" }),
|
||||||
|
];
|
||||||
|
rerender(<NotificationAlerts />);
|
||||||
|
|
||||||
|
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(<NotificationAlerts />);
|
||||||
|
|
||||||
|
mockStream.list = [
|
||||||
|
...mockStream.list,
|
||||||
|
notification({ notification_id: "3", subject: "Silenced" }),
|
||||||
|
];
|
||||||
|
rerender(<NotificationAlerts />);
|
||||||
|
|
||||||
|
expect(toast).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("plays a chime via Web Audio when sound is enabled", () => {
|
||||||
|
const ctor = stubAudioContext();
|
||||||
|
const { rerender } = render(<NotificationAlerts />);
|
||||||
|
|
||||||
|
mockStream.list = [
|
||||||
|
...mockStream.list,
|
||||||
|
notification({ notification_id: "4", subject: "Ping" }),
|
||||||
|
];
|
||||||
|
rerender(<NotificationAlerts />);
|
||||||
|
|
||||||
|
expect(ctor).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the chime when sound is disabled, but still toasts", () => {
|
||||||
|
const ctor = stubAudioContext();
|
||||||
|
mockUiStore.soundEnabled = false;
|
||||||
|
const { rerender } = render(<NotificationAlerts />);
|
||||||
|
|
||||||
|
mockStream.list = [
|
||||||
|
...mockStream.list,
|
||||||
|
notification({ notification_id: "5", subject: "Quiet" }),
|
||||||
|
];
|
||||||
|
rerender(<NotificationAlerts />);
|
||||||
|
|
||||||
|
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(<NotificationAlerts />);
|
||||||
|
|
||||||
|
mockStream.list = [
|
||||||
|
...mockStream.list,
|
||||||
|
notification({ notification_id: "6", subject: "Still fine" }),
|
||||||
|
];
|
||||||
|
expect(() => rerender(<NotificationAlerts />)).not.toThrow();
|
||||||
|
expect(toast).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<number | null>(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;
|
||||||
|
}
|
||||||
@@ -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<void> }) {
|
||||||
|
const { register } = usePageRefresh();
|
||||||
|
const [registered, setRegistered] = useState(false);
|
||||||
|
if (!registered) {
|
||||||
|
register(callback);
|
||||||
|
setRegistered(true);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Harness({
|
||||||
|
callback,
|
||||||
|
mountRegistrator = true,
|
||||||
|
}: {
|
||||||
|
callback: () => void | Promise<void>;
|
||||||
|
mountRegistrator?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<PageRefreshProvider>
|
||||||
|
{mountRegistrator && <Registrator callback={callback} />}
|
||||||
|
<AutoRefreshDriver />
|
||||||
|
</PageRefreshProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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(<Harness callback={vi.fn()} mountRegistrator={false} />);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("arms no interval when Auto Refresh is off, even with a callback registered", () => {
|
||||||
|
render(<Harness callback={vi.fn()} />);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("fires refresh at the configured interval when enabled + registered", async () => {
|
||||||
|
mockStore.autoRefresh = true;
|
||||||
|
const callback = vi.fn();
|
||||||
|
render(<Harness callback={callback} />);
|
||||||
|
|
||||||
|
// 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(<Harness callback={callback} />);
|
||||||
|
|
||||||
|
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(<Harness callback={callback} />);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
});
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1);
|
||||||
|
|
||||||
|
mockStore.autoRefresh = false;
|
||||||
|
rerender(<Harness callback={callback} />);
|
||||||
|
expect(vi.getTimerCount()).toBe(0);
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
vi.advanceTimersByTime(10_000);
|
||||||
|
});
|
||||||
|
expect(callback).toHaveBeenCalledTimes(1); // unchanged
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -17,12 +17,24 @@ interface UIState {
|
|||||||
// design doc §1) — same persisted-preference idiom as sidebar/theme.
|
// design doc §1) — same persisted-preference idiom as sidebar/theme.
|
||||||
a2aContextOpen: boolean;
|
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
|
// Actions
|
||||||
toggleSidebar: () => void;
|
toggleSidebar: () => void;
|
||||||
setSidebarCollapsed: (collapsed: boolean) => void;
|
setSidebarCollapsed: (collapsed: boolean) => void;
|
||||||
setTheme: (theme: "light" | "dark" | "system") => void;
|
setTheme: (theme: "light" | "dark" | "system") => void;
|
||||||
setCurrentTeam: (team: Team | null) => void;
|
setCurrentTeam: (team: Team | null) => void;
|
||||||
toggleA2AContext: () => void;
|
toggleA2AContext: () => void;
|
||||||
|
setNotificationsEnabled: (enabled: boolean) => void;
|
||||||
|
setSoundEnabled: (enabled: boolean) => void;
|
||||||
|
setAutoRefresh: (enabled: boolean) => void;
|
||||||
|
setRefreshIntervalSeconds: (seconds: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useUIStore = create<UIState>()(
|
export const useUIStore = create<UIState>()(
|
||||||
@@ -33,6 +45,10 @@ export const useUIStore = create<UIState>()(
|
|||||||
theme: "system",
|
theme: "system",
|
||||||
currentTeam: null,
|
currentTeam: null,
|
||||||
a2aContextOpen: true,
|
a2aContextOpen: true,
|
||||||
|
notificationsEnabled: true,
|
||||||
|
soundEnabled: true,
|
||||||
|
autoRefresh: false, // default-off: never start a background poller unasked
|
||||||
|
refreshIntervalSeconds: 30,
|
||||||
|
|
||||||
toggleSidebar: () =>
|
toggleSidebar: () =>
|
||||||
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
set((state) => ({ sidebarOpen: !state.sidebarOpen })),
|
||||||
@@ -41,6 +57,12 @@ export const useUIStore = create<UIState>()(
|
|||||||
setCurrentTeam: (team) => set({ currentTeam: team }),
|
setCurrentTeam: (team) => set({ currentTeam: team }),
|
||||||
toggleA2AContext: () =>
|
toggleA2AContext: () =>
|
||||||
set((state) => ({ a2aContextOpen: !state.a2aContextOpen })),
|
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",
|
name: "roboco-ui-storage",
|
||||||
@@ -49,6 +71,10 @@ export const useUIStore = create<UIState>()(
|
|||||||
theme: state.theme,
|
theme: state.theme,
|
||||||
currentTeam: state.currentTeam,
|
currentTeam: state.currentTeam,
|
||||||
a2aContextOpen: state.a2aContextOpen,
|
a2aContextOpen: state.a2aContextOpen,
|
||||||
|
notificationsEnabled: state.notificationsEnabled,
|
||||||
|
soundEnabled: state.soundEnabled,
|
||||||
|
autoRefresh: state.autoRefresh,
|
||||||
|
refreshIntervalSeconds: state.refreshIntervalSeconds,
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|||||||
Reference in New Issue
Block a user