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,