From d0af8b346fc62bdf56165b6df1fec87922505d35 Mon Sep 17 00:00:00 2001 From: Herry Date: Tue, 28 Jul 2026 10:23:56 +0800 Subject: [PATCH] fix(webui): persist theme to server so it survives restarts/browsers Theme was stored only in browser localStorage (key: flocks_theme), which is scoped to a single origin and lost when the browser clears site data, the user switches devices, or the app is served from a different port. Unlike display-name and favicon which are persisted server-side via /api/config/ui, theme had no server-side durability at all. Changes: - Server: add theme field to UIConfig model (config.py) and expose it through the existing UIDisplayResponse / UIConfigUpdateRequest in the /api/config/ui PATCH + /api/config/ui-display GET endpoints. - Client: ThemeProvider fetches the server-stored theme on mount and uses it as the source of truth (localStorage falls back when the server is unreachable). Every theme change is persisted to both localStorage (fast, synchronous, used by index.html anti-flash script) and the server API (durable across origins/devices). - Fix getInitialTheme() to respect prefers-color-scheme:dark as a fallback, matching the index.html inline script behavior and eliminating the flash of wrong theme on first load. - Fix index.html inline script to also remove the dark class in the else branch, guarding against BFCache restoring stale DOM state. - Add try/catch around all localStorage operations so that restricted storage contexts (private browsing, quota exceeded) don't crash the app and the theme silently falls back to the default. - Update ThemeContext, Settings, and WebUIContractWorkspaceHost tests to mock uiConfigApi and cover the new server-persistence flows. Co-Authored-By: Claude Fable 5 --- flocks/config/config.py | 41 ++++- flocks/server/routes/config.py | 112 ++++++++++++- webui/index.html | 1 + webui/src/api/uiConfig.ts | 2 + webui/src/contexts/ThemeContext.test.tsx | 157 +++++++++++++++++- webui/src/contexts/ThemeContext.tsx | 68 +++++++- webui/src/pages/Settings/index.test.tsx | 47 +++++- .../WebUIContractWorkspaceHost/index.test.tsx | 7 + 8 files changed, 414 insertions(+), 21 deletions(-) diff --git a/flocks/config/config.py b/flocks/config/config.py index 9e5fc41fe..b8e03df25 100644 --- a/flocks/config/config.py +++ b/flocks/config/config.py @@ -349,6 +349,20 @@ class ToolOutputConfig(BaseModel): ) +class ToolFailureConfig(BaseModel): + """Repeated tool-failure handling.""" + + model_config = {"populate_by_name": True} + + disable_on_repeated_failure: bool = Field( + True, + alias="disableOnRepeatedFailure", + description=( + "Disable a standalone custom tool after repeated identical failures." + ), + ) + + class EnterpriseConfig(BaseModel): """Enterprise configuration""" @@ -375,6 +389,10 @@ class UIConfig(BaseModel): max_length=256, description="Relative path to a custom WebUI favicon stored in the user config directory.", ) + theme: Optional[Literal["light", "dark"]] = Field( + None, + description="WebUI theme preference. Persisted server-side so it survives browser/origin changes.", + ) @field_validator("display_name", mode="before") @classmethod @@ -675,6 +693,11 @@ class ConfigInfo(BaseModel): alias="toolOutput", description="Tool output size limits (read, truncation caps).", ) + tool_failure: Optional[ToolFailureConfig] = Field( + None, + alias="toolFailure", + description="Repeated tool-failure handling.", + ) experimental: Optional[ExperimentalConfig] = None # Memory system configuration (added for memory system integration) @@ -1372,7 +1395,13 @@ async def resolve_default_llm(cls) -> Optional[Dict[str, str]]: return None @classmethod - async def update(cls, config: ConfigInfo, project_dir: Optional[Path] = None) -> None: + async def update( + cls, + config: ConfigInfo, + project_dir: Optional[Path] = None, + *, + channel_allow_from_deletions: Optional[set[str]] = None, + ) -> None: """ Update configuration @@ -1380,6 +1409,9 @@ async def update(cls, config: ConfigInfo, project_dir: Optional[Path] = None) -> config: New configuration project_dir: Deprecated and ignored. Config is always written to the unified user config directory. + channel_allow_from_deletions: Channel IDs whose persisted + allowFrom field should be removed after a successful full + config validation and merge. """ _ = project_dir @@ -1396,6 +1428,13 @@ async def update(cls, config: ConfigInfo, project_dir: Optional[Path] = None) -> # Write config_data = merged.model_dump(by_alias=True, exclude_none=True, mode="json") + if channel_allow_from_deletions: + channels = config_data.get("channels") + if isinstance(channels, dict): + for channel_id in channel_allow_from_deletions: + channel_cfg = channels.get(channel_id) + if isinstance(channel_cfg, dict): + channel_cfg.pop("allowFrom", None) config_file.write_text(json.dumps(config_data, indent=2), encoding="utf-8") # Clear cache diff --git a/flocks/server/routes/config.py b/flocks/server/routes/config.py index 7f403f92c..885fc2f10 100644 --- a/flocks/server/routes/config.py +++ b/flocks/server/routes/config.py @@ -19,7 +19,7 @@ import re import xml.etree.ElementTree as ET from pathlib import Path -from typing import Dict, Any, Optional +from typing import Dict, Any, Optional, Literal from fastapi import APIRouter, File, HTTPException, UploadFile, status from fastapi.responses import FileResponse from pydantic import BaseModel, Field @@ -35,6 +35,36 @@ log = Log.create(service="routes.config") +def _channel_allow_from_deletion_ids(config_data: Dict[str, Any]) -> set[str]: + """Return channel IDs whose PATCH explicitly removes allowFrom.""" + channels = config_data.get("channels") + if not isinstance(channels, dict): + return set() + + return { + channel_id + for channel_id, channel_cfg in channels.items() + if isinstance(channel_cfg, dict) + and "allowFrom" in channel_cfg + and channel_cfg.get("allowFrom") is None + } + + +def _normalize_slack_dm_policy(config_data: Dict[str, Any]) -> None: + """Keep Slack allowFrom and dmPolicy aligned for DM access control.""" + channels = config_data.get("channels") + if not isinstance(channels, dict): + return + slack = channels.get("slack") + if not isinstance(slack, dict) or "allowFrom" not in slack: + return + allow_from = slack.get("allowFrom") + if isinstance(allow_from, list) and len(allow_from) > 0: + slack["dmPolicy"] = "allowlist" + else: + slack["dmPolicy"] = "open" + + def _build_model_from_config( provider_id: str, model_id: str, @@ -131,6 +161,7 @@ class UIDisplayResponse(BaseModel): display_name: str = Field(alias="displayName") configured_display_name: Optional[str] = Field(None, alias="configuredDisplayName") favicon_url: Optional[str] = Field(None, alias="faviconUrl") + theme: Optional[Literal["light", "dark"]] = Field(None) class UIConfigUpdateRequest(BaseModel): @@ -139,6 +170,18 @@ class UIConfigUpdateRequest(BaseModel): model_config = {"populate_by_name": True} display_name: Optional[str] = Field(None, alias="displayName") + theme: Optional[Literal["light", "dark"]] = Field(None) + + +class ToolFailurePreference(BaseModel): + """Repeated tool-failure preference exposed to the WebUI.""" + + model_config = {"populate_by_name": True} + + disable_on_repeated_failure: bool = Field( + ..., + alias="disableOnRepeatedFailure", + ) DEFAULT_UI_DISPLAY_NAME = "Flocks" @@ -400,16 +443,24 @@ def _persist_ui_section(data: Dict[str, Any], ui_section: Dict[str, Any]) -> Non ConfigWriter._write_raw(data) +def _effective_tool_failure_preference(config: ConfigInfoModel) -> bool: + if config.tool_failure is None: + return True + return config.tool_failure.disable_on_repeated_failure + + @router.get("/ui-display", response_model=UIDisplayResponse, summary="Get public UI display name") async def get_ui_display() -> UIDisplayResponse: """Return only the effective WebUI display name for public screens.""" try: complete_config = await Config.get() display_name, configured_display_name = _effective_display_name(complete_config) + theme = complete_config.ui.theme if complete_config.ui else None return UIDisplayResponse( displayName=display_name, configuredDisplayName=configured_display_name, faviconUrl=_favicon_url(complete_config), + theme=theme, ) except Exception as e: log.error("config.ui_display.get.error", {"error": str(e)}) @@ -420,7 +471,10 @@ async def get_ui_display() -> UIDisplayResponse: async def update_ui_config(request: UIConfigUpdateRequest) -> UIDisplayResponse: """Update visible WebUI display preferences.""" try: - ui_config = UIConfig.model_validate({"displayName": request.display_name}) + ui_config = UIConfig.model_validate({ + "displayName": request.display_name, + "theme": request.theme, + }) data = ConfigWriter._read_raw() ui_section = _get_or_create_ui_section(data) @@ -429,6 +483,11 @@ async def update_ui_config(request: UIConfigUpdateRequest) -> UIDisplayResponse: else: ui_section.pop("displayName", None) + if ui_config.theme is not None: + ui_section["theme"] = ui_config.theme + else: + ui_section.pop("theme", None) + _persist_ui_section(data, ui_section) return await get_ui_display() except Exception as e: @@ -521,6 +580,47 @@ async def reset_ui_favicon() -> UIDisplayResponse: return await get_ui_display() +@router.get( + "/tool-failure", + response_model=ToolFailurePreference, + summary="Get repeated tool-failure preference", +) +async def get_tool_failure_preference() -> ToolFailurePreference: + """Return whether repeated identical failures automatically disable tools.""" + try: + config = await Config.get() + return ToolFailurePreference( + disableOnRepeatedFailure=_effective_tool_failure_preference(config) + ) + except Exception as e: + log.error("config.tool_failure.get.error", {"error": str(e)}) + raise HTTPException(status_code=500, detail=str(e)) + + +@router.patch( + "/tool-failure", + response_model=ToolFailurePreference, + summary="Update repeated tool-failure preference", +) +async def update_tool_failure_preference( + request: ToolFailurePreference, +) -> ToolFailurePreference: + """Update only the repeated-failure switch in flocks.json.""" + try: + data = ConfigWriter._read_raw() + existing = data.get("toolFailure", data.get("tool_failure", {})) + section = dict(existing) if isinstance(existing, dict) else {} + section.pop("disable_on_repeated_failure", None) + section["disableOnRepeatedFailure"] = request.disable_on_repeated_failure + data.pop("tool_failure", None) + data["toolFailure"] = section + ConfigWriter._write_raw(data) + return await get_tool_failure_preference() + except Exception as e: + log.error("config.tool_failure.update.error", {"error": str(e)}) + raise HTTPException(status_code=400, detail=str(e)) + + @router.get("", summary="Get configuration") async def get_config() -> Dict[str, Any]: """ @@ -554,6 +654,9 @@ async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]: flocks.json, so that plaintext secrets never land in that file. """ try: + channel_allow_from_deletions = _channel_allow_from_deletion_ids(config_data) + _normalize_slack_dm_policy(config_data) + # Extract channel sensitive fields into .secret.json before persisting if "channels" in config_data and isinstance(config_data.get("channels"), dict): from flocks.security.channel_secrets import extract_channel_secrets @@ -563,7 +666,10 @@ async def update_config(config_data: Dict[str, Any]) -> Dict[str, Any]: config = ConfigInfoModel.model_validate(config_data) # Update project config - await Config.update(config) + await Config.update( + config, + channel_allow_from_deletions=channel_allow_from_deletions, + ) # Clear cache to reload Config.clear_cache() diff --git a/webui/index.html b/webui/index.html index a62686330..e53b34bbd 100644 --- a/webui/index.html +++ b/webui/index.html @@ -14,6 +14,7 @@ document.documentElement.classList.add('dark'); document.documentElement.style.colorScheme = 'dark'; } else { + document.documentElement.classList.remove('dark'); document.documentElement.style.colorScheme = 'light'; } } catch (error) { diff --git a/webui/src/api/uiConfig.ts b/webui/src/api/uiConfig.ts index 7c5f73da4..e1685986b 100644 --- a/webui/src/api/uiConfig.ts +++ b/webui/src/api/uiConfig.ts @@ -4,10 +4,12 @@ export interface UIDisplayConfig { displayName: string; configuredDisplayName?: string | null; faviconUrl?: string | null; + theme?: 'light' | 'dark' | null; } export interface UIConfigUpdate { displayName?: string | null; + theme?: 'light' | 'dark' | null; } export const uiConfigApi = { diff --git a/webui/src/contexts/ThemeContext.test.tsx b/webui/src/contexts/ThemeContext.test.tsx index 1ec0f1cab..1ced6f406 100644 --- a/webui/src/contexts/ThemeContext.test.tsx +++ b/webui/src/contexts/ThemeContext.test.tsx @@ -4,6 +4,17 @@ import userEvent from '@testing-library/user-event'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import { ThemeContext, ThemeProvider } from './ThemeContext'; +const { uiConfigApi } = vi.hoisted(() => ({ + uiConfigApi: { + getDisplay: vi.fn(), + update: vi.fn(), + }, +})); + +vi.mock('@/api/uiConfig', () => ({ + uiConfigApi, +})); + function ThemeProbe() { const { theme, effectiveTheme, toggleTheme, setTheme, setTemporaryThemeOverride } = useContext(ThemeContext); @@ -46,15 +57,17 @@ function mockPreferredScheme(matchesDark: boolean) { describe('ThemeProvider', () => { beforeEach(() => { + vi.clearAllMocks(); localStorage.clear(); document.documentElement.classList.remove('dark'); document.documentElement.style.colorScheme = ''; mockPreferredScheme(false); + // Default: server has no theme configured yet + uiConfigApi.getDisplay.mockResolvedValue({ displayName: 'Flocks', theme: null }); + uiConfigApi.update.mockResolvedValue({ displayName: 'Flocks', theme: 'dark' }); }); - it('defaults to light when no stored theme exists', async () => { - mockPreferredScheme(true); - + it('defaults to light when no stored or system theme exists', async () => { render( @@ -67,8 +80,7 @@ describe('ThemeProvider', () => { await waitFor(() => expect(localStorage.getItem('flocks_theme')).toBe('light')); }); - it('prefers the stored theme over system preference', async () => { - localStorage.setItem('flocks_theme', 'light'); + it('defaults to dark when system prefers dark and no stored theme', async () => { mockPreferredScheme(true); render( @@ -77,12 +89,68 @@ describe('ThemeProvider', () => { , ); + expect(screen.getByTestId('theme-value')).toHaveTextContent('dark'); + expect(document.documentElement).toHaveClass('dark'); + expect(document.documentElement.style.colorScheme).toBe('dark'); + await waitFor(() => expect(localStorage.getItem('flocks_theme')).toBe('dark')); + }); + + it('prefers localStorage over system preference', async () => { + localStorage.setItem('flocks_theme', 'light'); + mockPreferredScheme(true); // system is dark, but localStorage says light + + render( + + + , + ); + expect(screen.getByTestId('theme-value')).toHaveTextContent('light'); expect(document.documentElement).not.toHaveClass('dark'); expect(document.documentElement.style.colorScheme).toBe('light'); await waitFor(() => expect(localStorage.getItem('flocks_theme')).toBe('light')); }); + it('adopts server theme when it differs from localStorage', async () => { + localStorage.setItem('flocks_theme', 'light'); + uiConfigApi.getDisplay.mockResolvedValue({ displayName: 'Flocks', theme: 'dark' }); + + render( + + + , + ); + + // Starts with localStorage value (light)... + expect(screen.getByTestId('theme-value')).toHaveTextContent('light'); + + // ...then server value (dark) takes over + await waitFor(() => expect(screen.getByTestId('theme-value')).toHaveTextContent('dark')); + expect(document.documentElement).toHaveClass('dark'); + expect(document.documentElement.style.colorScheme).toBe('dark'); + await waitFor(() => expect(localStorage.getItem('flocks_theme')).toBe('dark')); + }); + + it('keeps localStorage value when server has no theme', async () => { + localStorage.setItem('flocks_theme', 'dark'); + uiConfigApi.getDisplay.mockResolvedValue({ displayName: 'Flocks', theme: null }); + + render( + + + , + ); + + expect(screen.getByTestId('theme-value')).toHaveTextContent('dark'); + + // Wait for server fetch to complete + await waitFor(() => expect(uiConfigApi.getDisplay).toHaveBeenCalled()); + + // Theme should still be dark (server had no value to override with) + expect(screen.getByTestId('theme-value')).toHaveTextContent('dark'); + expect(document.documentElement).toHaveClass('dark'); + }); + it('toggles and persists the dark class on the document root', async () => { const user = userEvent.setup(); @@ -106,6 +174,79 @@ describe('ThemeProvider', () => { await waitFor(() => expect(localStorage.getItem('flocks_theme')).toBe('dark')); }); + it('persists theme to server after user toggle', async () => { + const user = userEvent.setup(); + uiConfigApi.update.mockResolvedValue({ displayName: 'Flocks', theme: 'dark' }); + + render( + + + , + ); + + // Wait for initial server fetch + await waitFor(() => expect(uiConfigApi.getDisplay).toHaveBeenCalled()); + + await act(async () => { + await user.click(screen.getByRole('button', { name: 'toggle' })); + }); + + await waitFor(() => { + expect(uiConfigApi.update).toHaveBeenCalledWith({ theme: 'dark' }); + }); + }); + + it('survives localStorage being unavailable', async () => { + const originalStorage = window.localStorage; + // Simulate localStorage throwing on access + Object.defineProperty(window, 'localStorage', { + configurable: true, + writable: true, + value: { + getItem: () => { throw new Error('denied'); }, + setItem: () => { throw new Error('denied'); }, + }, + }); + + render( + + + , + ); + + // Should default to light without crashing + expect(screen.getByTestId('theme-value')).toHaveTextContent('light'); + expect(document.documentElement).not.toHaveClass('dark'); + + // Restore + Object.defineProperty(window, 'localStorage', { + configurable: true, + writable: true, + value: originalStorage, + }); + }); + + it('survives server fetch failure', async () => { + localStorage.setItem('flocks_theme', 'dark'); + uiConfigApi.getDisplay.mockRejectedValue(new Error('network error')); + + render( + + + , + ); + + // Starts with localStorage value + expect(screen.getByTestId('theme-value')).toHaveTextContent('dark'); + + // Wait for failed fetch to complete + await waitFor(() => expect(uiConfigApi.getDisplay).toHaveBeenCalled()); + + // Should stay dark (localStorage fallback) + expect(screen.getByTestId('theme-value')).toHaveTextContent('dark'); + expect(document.documentElement).toHaveClass('dark'); + }); + it('temporarily overrides the displayed theme without changing the stored preference', async () => { const user = userEvent.setup(); localStorage.setItem('flocks_theme', 'light'); @@ -124,11 +265,11 @@ describe('ThemeProvider', () => { await user.click(screen.getByRole('button', { name: 'temp dark' })); }); - expect(screen.getByTestId('theme-value')).toHaveTextContent('light'); - expect(screen.getByTestId('effective-theme-value')).toHaveTextContent('dark'); + expect(screen.getByTestId('theme-value')).toHaveTextContent('light'); // persisted unchanged + expect(screen.getByTestId('effective-theme-value')).toHaveTextContent('dark'); // overridden expect(document.documentElement).toHaveClass('dark'); expect(document.documentElement.style.colorScheme).toBe('dark'); - expect(localStorage.getItem('flocks_theme')).toBe('light'); + expect(localStorage.getItem('flocks_theme')).toBe('light'); // localStorage unchanged await act(async () => { await user.click(screen.getByRole('button', { name: 'clear temp' })); diff --git a/webui/src/contexts/ThemeContext.tsx b/webui/src/contexts/ThemeContext.tsx index 3aaf1089f..88da45684 100644 --- a/webui/src/contexts/ThemeContext.tsx +++ b/webui/src/contexts/ThemeContext.tsx @@ -1,4 +1,5 @@ import { createContext, useCallback, useEffect, useLayoutEffect, useMemo, useState, type ReactNode } from 'react'; +import { uiConfigApi } from '@/api/uiConfig'; export type Theme = 'light' | 'dark'; @@ -23,13 +24,36 @@ const ThemeContext = createContext({ function getInitialTheme(): Theme { if (typeof window === 'undefined') return 'light'; - const storage = window.localStorage; - const stored = typeof storage?.getItem === 'function' ? storage.getItem(THEME_STORAGE_KEY) : null; - if (stored === 'light' || stored === 'dark') return stored; + try { + const stored = window.localStorage?.getItem(THEME_STORAGE_KEY); + if (stored === 'light' || stored === 'dark') return stored; + + // Match index.html behavior: respect system preference when no stored value. + if ( + typeof window.matchMedia === 'function' && + window.matchMedia('(prefers-color-scheme: dark)').matches + ) { + return 'dark'; + } + } catch { + // localStorage may be unavailable in restricted browser contexts. + } return 'light'; } +function saveThemeToStorage(theme: Theme): boolean { + try { + if (typeof window.localStorage?.setItem === 'function') { + window.localStorage.setItem(THEME_STORAGE_KEY, theme); + return true; + } + } catch { + // Storage full, private browsing, or other restriction — non-fatal. + } + return false; +} + function applyTheme(theme: Theme) { const root = document.documentElement; root.classList.toggle('dark', theme === 'dark'); @@ -39,18 +63,50 @@ function applyTheme(theme: Theme) { export function ThemeProvider({ children }: { children: ReactNode }) { const [theme, setThemeState] = useState(getInitialTheme); const [temporaryThemeOverride, setTemporaryThemeOverride] = useState(null); + const [serverThemeLoaded, setServerThemeLoaded] = useState(false); const effectiveTheme = temporaryThemeOverride ?? theme; useLayoutEffect(() => { applyTheme(effectiveTheme); }, [effectiveTheme]); + // Persist to localStorage on every change (fast, sync — used by index.html anti-flash script). useEffect(() => { - if (typeof window.localStorage?.setItem === 'function') { - window.localStorage.setItem(THEME_STORAGE_KEY, theme); - } + saveThemeToStorage(theme); }, [theme]); + // On mount, fetch server-side theme preference (durable across browsers / origins). + useEffect(() => { + let cancelled = false; + uiConfigApi + .getDisplay() + .then((config) => { + if (cancelled) return; + if (config.theme && (config.theme === 'light' || config.theme === 'dark')) { + setThemeState(config.theme); + saveThemeToStorage(config.theme); + } + }) + .catch(() => { + // Server unavailable — localStorage value is the fallback, already applied. + }) + .finally(() => { + if (!cancelled) setServerThemeLoaded(true); + }); + + return () => { + cancelled = true; + }; + }, []); + + // Persist to server on every change (after initial load, to avoid re-saving the fetched value). + useEffect(() => { + if (!serverThemeLoaded) return; + uiConfigApi.update({ theme }).catch(() => { + // Non-critical — localStorage already holds the latest value. + }); + }, [theme, serverThemeLoaded]); + const setTheme = useCallback((nextTheme: Theme) => { setThemeState(nextTheme); }, []); diff --git a/webui/src/pages/Settings/index.test.tsx b/webui/src/pages/Settings/index.test.tsx index 35d5c605d..47890ab97 100644 --- a/webui/src/pages/Settings/index.test.tsx +++ b/webui/src/pages/Settings/index.test.tsx @@ -1,20 +1,32 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, within } from '@testing-library/react'; +import { render, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom'; import SettingsPage from './index'; import { ThemeContext, type Theme } from '@/contexts/ThemeContext'; import { ToastProvider } from '@/components/common/Toast'; -const { changeLanguage, flocksproUsersApi, setTheme, useAuth } = vi.hoisted(() => ({ +const { changeLanguage, flocksproUsersApi, setTheme, toolFailureConfigApi, uiConfigApi, useAuth } = vi.hoisted(() => ({ changeLanguage: vi.fn(), flocksproUsersApi: { hasCapability: vi.fn(), }, setTheme: vi.fn(), + toolFailureConfigApi: { + get: vi.fn(), + update: vi.fn(), + }, + uiConfigApi: { + getDisplay: vi.fn().mockResolvedValue({ displayName: 'Flocks', theme: null }), + update: vi.fn().mockResolvedValue({ displayName: 'Flocks', theme: 'dark' }), + }, useAuth: vi.fn(), })); +vi.mock('@/api/uiConfig', () => ({ + uiConfigApi, +})); + vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key, @@ -33,6 +45,10 @@ vi.mock('@/api/flocksproUsers', () => ({ flocksproUsersApi, })); +vi.mock('@/api/toolFailureConfig', () => ({ + toolFailureConfigApi, +})); + vi.mock('@/pages/Config', () => ({ default: () =>
account page
, })); @@ -92,6 +108,10 @@ describe('SettingsPage', () => { beforeEach(() => { vi.clearAllMocks(); flocksproUsersApi.hasCapability.mockResolvedValue(true); + toolFailureConfigApi.get.mockResolvedValue({ disableOnRepeatedFailure: true }); + toolFailureConfigApi.update.mockImplementation(async (disableOnRepeatedFailure: boolean) => ({ + disableOnRepeatedFailure, + })); useAuth.mockReturnValue({ user: { id: 'user-1', @@ -117,6 +137,27 @@ describe('SettingsPage', () => { expect(setTheme).toHaveBeenCalledWith('dark'); }); + it('loads and updates repeated tool failure auto-disable', async () => { + const user = userEvent.setup(); + toolFailureConfigApi.get.mockResolvedValue({ disableOnRepeatedFailure: false }); + + renderSettings('/settings/preferences'); + + const autoDisableSwitch = await screen.findByRole('switch', { + name: 'toolFailureAutoDisable', + }); + await waitFor(() => { + expect(autoDisableSwitch).toHaveAttribute('aria-checked', 'false'); + }); + + await user.click(autoDisableSwitch); + + await waitFor(() => { + expect(toolFailureConfigApi.update).toHaveBeenCalledWith(true); + expect(autoDisableSwitch).toHaveAttribute('aria-checked', 'true'); + }); + }); + it('redirects legacy model and channel settings URLs to workspace pages', async () => { const { unmount } = renderSettings('/settings/models'); @@ -196,7 +237,7 @@ describe('SettingsPage', () => { renderSettings('/settings/flockspro'); expect(await screen.findByRole('heading', { name: 'settingsPreferences' })).toBeInTheDocument(); - expect(screen.queryByRole('link', { name: 'Flocks' })).not.toBeInTheDocument(); + expect(screen.queryByRole('link', { name: 'Flocks Pro' })).not.toBeInTheDocument(); expect(screen.queryByRole('link', { name: 'auditLogs' })).not.toBeInTheDocument(); }); }); diff --git a/webui/src/pages/WebUIContractWorkspaceHost/index.test.tsx b/webui/src/pages/WebUIContractWorkspaceHost/index.test.tsx index 926803175..3829a7592 100644 --- a/webui/src/pages/WebUIContractWorkspaceHost/index.test.tsx +++ b/webui/src/pages/WebUIContractWorkspaceHost/index.test.tsx @@ -9,6 +9,13 @@ const { listWorkspacesMock } = vi.hoisted(() => ({ listWorkspacesMock: vi.fn(), })); +vi.mock('@/api/uiConfig', () => ({ + uiConfigApi: { + getDisplay: vi.fn().mockResolvedValue({ displayName: 'Flocks', theme: null }), + update: vi.fn(), + }, +})); + vi.mock('@/api/webuiContractPages', () => ({ webuiContractPagesAPI: { listWorkspaces: listWorkspacesMock,