From f94e1d73e29605ad0dd0369667382897fd356a1b Mon Sep 17 00:00:00 2001 From: Xubin Ren <52506698+Re-bin@users.noreply.github.com> Date: Wed, 12 Aug 2026 02:08:28 +0900 Subject: [PATCH] feat(webui): redesign apps discovery --- nanobot/apps/cli/service.py | 37 ++- nanobot/apps/discovery.py | 129 ++++++++ nanobot/webui/settings_routes.py | 3 + nanobot/webui/settings_system.py | 12 + tests/cli_apps/test_discovery.py | 65 ++++ tests/cli_apps/test_service.py | 70 ++++ tests/webui/test_settings_routes.py | 19 ++ .../src/components/settings/SettingsPage.tsx | 2 + .../settings/overview/OverviewSettings.tsx | 4 +- .../settings/system/AppsSettings.tsx | 304 +++++++++++------- .../system/useSystemSettingsEffects.ts | 26 ++ .../settings/system/useSystemSettingsState.ts | 6 +- .../settings/useSettingsController.ts | 3 +- webui/src/i18n/locales/en/common.json | 15 +- webui/src/i18n/locales/es/common.json | 15 +- webui/src/i18n/locales/fr/common.json | 15 +- webui/src/i18n/locales/id/common.json | 15 +- webui/src/i18n/locales/ja/common.json | 15 +- webui/src/i18n/locales/ko/common.json | 15 +- webui/src/i18n/locales/pt-BR/common.json | 15 +- webui/src/i18n/locales/vi/common.json | 15 +- webui/src/i18n/locales/zh-CN/common.json | 15 +- webui/src/i18n/locales/zh-TW/common.json | 15 +- webui/src/lib/api.ts | 13 + webui/src/lib/local-preferences.ts | 14 +- webui/src/lib/types.ts | 7 + webui/src/tests/app-layout.test.tsx | 2 +- webui/src/tests/local-preferences.test.ts | 48 +++ webui/src/tests/settings-apps-oauth.test.tsx | 35 +- webui/src/tests/settings-overview.test.tsx | 20 +- webui/src/tests/settings-system.test.tsx | 117 ++++++- 31 files changed, 869 insertions(+), 217 deletions(-) create mode 100644 nanobot/apps/discovery.py create mode 100644 tests/cli_apps/test_discovery.py create mode 100644 webui/src/tests/local-preferences.test.ts diff --git a/nanobot/apps/cli/service.py b/nanobot/apps/cli/service.py index 4872207de..798128f92 100644 --- a/nanobot/apps/cli/service.py +++ b/nanobot/apps/cli/service.py @@ -104,7 +104,6 @@ _BRANDS: dict[str, tuple[str, str]] = { "audacity": ("audacity", "#0000CC"), "blender": ("blender", "#E87D0D"), "browser": ("googlechrome", "#4285F4"), - "calibre": ("calibre", "#45B29D"), "chromadb": ("chroma", "#FFDE2D"), "comfyui": ("comfyui", "#111827"), "contentful": ("contentful", "#2478CC"), @@ -158,6 +157,7 @@ _BRANDS: dict[str, tuple[str, str]] = { _BRAND_DOMAINS: dict[str, tuple[str, str]] = { "3mf": ("3mf.io", "#00A1DE"), "anygen": ("anygen.io", "#111827"), + "calibre": ("calibre-ebook.com", "#45B29D"), "clibrowser": ("github.com/allthingssecurity/clibrowser", "#24292F"), "cloudanalyzer": ("github.com/rsasaki0109/CloudAnalyzer", "#2563EB"), "cloudcompare": ("cloudcompare.org", "#4D83C3"), @@ -201,6 +201,13 @@ _BRAND_ALIASES: dict[str, str] = { } _BRAND_TRAILING_WORDS = ("cli", "workflow", "workflows", "app", "apps", "tool", "tools") +_GENERIC_HOMEPAGE_HOSTS = frozenset({ + "bitbucket.org", + "github.com", + "gitlab.com", + "npmjs.com", + "pypi.org", +}) def _now() -> float: @@ -333,11 +340,25 @@ def _brand_candidates(app: dict[str, Any]) -> list[str]: return candidates +def _homepage_domain(app: dict[str, Any]) -> str | None: + value = str(app.get("homepage") or "").strip() + try: + parsed = urlparse(value) + except ValueError: + return None + host = (parsed.hostname or "").lower().removeprefix("www.") + if parsed.scheme not in {"http", "https"} or host in _GENERIC_HOMEPAGE_HOSTS: + return None + if not host or "." not in host or any(not label for label in host.split(".")): + return None + return host + + def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]: declared_logo = str(app.get("logo_url") or "").strip() + declared_color = str(app.get("brand_color") or "").strip() or None if declared_logo.startswith(("https://", "/")): - declared_color = str(app.get("brand_color") or "").strip() - return declared_logo, declared_color or None + return declared_logo, declared_color brand = None domain_brand = None @@ -349,13 +370,21 @@ def _brand_payload(app: dict[str, Any]) -> tuple[str | None, str | None]: domain_brand = _BRAND_DOMAINS.get(key) if domain_brand: break + + brand_color = declared_color or (brand or domain_brand or (None, None))[1] + homepage_domain = _homepage_domain(app) + if homepage_domain: + return ( + f"https://www.google.com/s2/favicons?domain={homepage_domain}&sz=64", + brand_color, + ) if not brand: if not domain_brand: return None, None domain, color = domain_brand return f"https://www.google.com/s2/favicons?domain={domain}&sz=64", color slug, color = brand - return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", color + return f"https://cdn.simpleicons.org/{slug}/{color.lstrip('#')}", brand_color def _read_json(path: Path) -> dict[str, Any] | None: diff --git a/nanobot/apps/discovery.py b/nanobot/apps/discovery.py new file mode 100644 index 000000000..a57498005 --- /dev/null +++ b/nanobot/apps/discovery.py @@ -0,0 +1,129 @@ +"""Small, fail-safe registry for the Apps page Featured section.""" + +from __future__ import annotations + +import asyncio +import json +import os +import re +import time +import urllib.request +from pathlib import Path +from typing import Any, cast + +REGISTRY_URL = "https://nanobot.wiki/registry/v1/discovery.json" +CACHE_TTL_S = 60 * 60 +_MAX_RESPONSE_BYTES = 64 * 1024 +_APP_ID_RE = re.compile(r"^(?:cli|mcp):[a-z0-9][a-z0-9._-]*$") +_FALLBACK = { + "schema_version": 1, + "updated_at": "2026-08-12T00:00:00Z", + "featured": [ + "mcp:github", + "mcp:playwright", + "mcp:notion", + "mcp:figma", + "mcp:context7", + "cli:obsidian", + "mcp:linear", + "cli:browser", + "cli:1password-cli", + "cli:blender", + "cli:libreoffice", + "cli:zotero", + ], +} +_refresh_tasks: dict[Path, asyncio.Task[None]] = {} + + +def _validated_payload(value: Any) -> dict[str, Any] | None: + if not isinstance(value, dict): + return None + payload = cast(dict[str, object], value) + if payload.get("schema_version") != 1: + return None + updated_at = payload.get("updated_at") + raw_featured = payload.get("featured") + if not isinstance(updated_at, str) or not updated_at.strip(): + return None + if not isinstance(raw_featured, list): + return None + featured_values = cast(list[object], raw_featured) + if not 1 <= len(featured_values) <= 12: + return None + featured: list[str] = [] + for item in featured_values: + if not isinstance(item, str) or _APP_ID_RE.fullmatch(item) is None: + return None + featured.append(item) + if len(featured) != len(set(featured)): + return None + return { + "schema_version": 1, + "updated_at": updated_at, + "featured": featured, + } + + +def _read_cache(path: Path) -> dict[str, Any] | None: + try: + return _validated_payload(json.loads(path.read_text(encoding="utf-8"))) + except (OSError, json.JSONDecodeError): + return None + + +def _fetch_remote() -> dict[str, Any]: + request = urllib.request.Request( + REGISTRY_URL, + headers={"Accept": "application/json", "User-Agent": "nanobot-apps/1"}, + ) + with urllib.request.urlopen(request, timeout=3) as response: + raw = response.read(_MAX_RESPONSE_BYTES + 1) + if len(raw) > _MAX_RESPONSE_BYTES: + raise ValueError("Apps discovery response is too large") + payload = _validated_payload(json.loads(raw)) + if payload is None: + raise ValueError("Invalid Apps discovery response") + return payload + + +def _write_cache(path: Path, payload: dict[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp") + try: + temporary.write_text(json.dumps(payload, ensure_ascii=False), encoding="utf-8") + os.replace(temporary, path) + finally: + temporary.unlink(missing_ok=True) + + +async def _refresh(path: Path) -> None: + try: + payload = await asyncio.to_thread(_fetch_remote) + await asyncio.to_thread(_write_cache, path, payload) + except Exception: + # Discovery is optional: the bundled list remains usable offline. + pass + + +def _schedule_refresh(path: Path) -> None: + task = _refresh_tasks.get(path) + if task is not None and not task.done(): + return + task = asyncio.create_task(_refresh(path)) + _refresh_tasks[path] = task + task.add_done_callback(lambda completed: _refresh_tasks.pop(path, None)) + + +async def discovery_payload(*, data_dir: Path) -> dict[str, Any]: + """Return cached Featured IDs immediately and refresh stale data in the background.""" + cache_path = data_dir / "apps-discovery.json" + cached = _read_cache(cache_path) + try: + fresh = cached is not None and time.time() - cache_path.stat().st_mtime < CACHE_TTL_S + except OSError: + fresh = False + if fresh and cached is not None: + return cached + _schedule_refresh(cache_path) + return {**(cached or _FALLBACK), "refresh_pending": True} diff --git a/nanobot/webui/settings_routes.py b/nanobot/webui/settings_routes.py index 633bff4d5..f47097e18 100644 --- a/nanobot/webui/settings_routes.py +++ b/nanobot/webui/settings_routes.py @@ -15,6 +15,7 @@ from nanobot.agent.tools.image_generation import request_image_generation_reload from nanobot.agent.tools.mcp import request_mcp_reload from nanobot.agent.tools.mcp_oauth import MCP_OAUTH_CALLBACK_PATH from nanobot.api.runtime import ApiRuntime, api_runtime_paths +from nanobot.apps.discovery import discovery_payload from nanobot.bus.queue import MessageBus from nanobot.channels.registry import load_channel_plugin from nanobot.channels.validation import validate_channel_config @@ -127,6 +128,7 @@ _CAPABILITY_ROUTES = { } _SYSTEM_ROUTES = { + "/api/settings/apps-discovery": "apps-discovery", "/api/settings/cli-apps": "cli-list", "/api/settings/cli-apps/install": "cli-install", "/api/settings/cli-apps/update": "cli-update", @@ -461,6 +463,7 @@ class WebUISettingsRouter: def _system_operations(self) -> system_domain.SystemSettingsOperations: return system_domain.SystemSettingsOperations( + apps_discovery_payload=discovery_payload, cli_apps_payload=cli_apps_payload, cli_apps_action=cli_apps_action, nanobot_features_payload=nanobot_features_payload, diff --git a/nanobot/webui/settings_system.py b/nanobot/webui/settings_system.py index f57394742..1e3acd667 100644 --- a/nanobot/webui/settings_system.py +++ b/nanobot/webui/settings_system.py @@ -43,6 +43,7 @@ SettingsOperation = Callable[..., Any] @dataclass(frozen=True) class SystemSettingsOperations: + apps_discovery_payload: SettingsOperation cli_apps_payload: SettingsOperation cli_apps_action: SettingsOperation nanobot_features_payload: SettingsOperation @@ -372,6 +373,8 @@ class SystemSettingsHandler: channel_name: str | None = None, connect_action: str | None = None, ) -> SettingsRouteResult: + if action == "apps-discovery": + return await self._apps_discovery(operations) if action == "cli-list": return await self._cli_apps(request, operations) if action.startswith("cli-"): @@ -419,6 +422,15 @@ class SystemSettingsHandler: return await self._version_check(operations) return SettingsRouteResult.failure(404, "unknown settings action") + async def _apps_discovery( + self, + operations: SystemSettingsOperations, + ) -> SettingsRouteResult: + payload = await operations.apps_discovery_payload( + data_dir=self.settings.config.path.parent / "catalog", + ) + return SettingsRouteResult.success(payload) + async def _cli_apps( self, request: SettingsRequest, diff --git a/tests/cli_apps/test_discovery.py b/tests/cli_apps/test_discovery.py new file mode 100644 index 000000000..fb9ddfd15 --- /dev/null +++ b/tests/cli_apps/test_discovery.py @@ -0,0 +1,65 @@ +from __future__ import annotations + +import asyncio +from pathlib import Path + +import pytest + +from nanobot.apps import discovery + + +@pytest.mark.asyncio +async def test_discovery_returns_fallback_then_caches_remote_registry( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + remote = { + "schema_version": 1, + "updated_at": "2026-08-12T01:00:00Z", + "featured": ["mcp:notion", "cli:obsidian"], + } + monkeypatch.setattr(discovery, "_fetch_remote", lambda: remote) + + initial = await discovery.discovery_payload(data_dir=tmp_path) + assert initial["featured"][0] == "mcp:github" + assert initial["refresh_pending"] is True + + await asyncio.gather(*discovery._refresh_tasks.values()) + cached = await discovery.discovery_payload(data_dir=tmp_path) + assert cached == remote + + +@pytest.mark.asyncio +async def test_discovery_keeps_last_valid_registry_when_refresh_fails( + monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, +) -> None: + cached = { + "schema_version": 1, + "updated_at": "2026-08-11T01:00:00Z", + "featured": ["mcp:github"], + } + discovery._write_cache(tmp_path / "apps-discovery.json", cached) + monkeypatch.setattr(discovery, "CACHE_TTL_S", -1) + monkeypatch.setattr( + discovery, + "_fetch_remote", + lambda: (_ for _ in ()).throw(OSError("offline")), + ) + + payload = await discovery.discovery_payload(data_dir=tmp_path) + assert payload == {**cached, "refresh_pending": True} + await asyncio.gather(*discovery._refresh_tasks.values()) + assert discovery._read_cache(tmp_path / "apps-discovery.json") == cached + + +@pytest.mark.parametrize( + "featured", + [[], ["unknown:github"], ["mcp:github", "mcp:github"], ["mcp:github"] * 13], +) +def test_discovery_rejects_invalid_featured_lists(featured: list[str]) -> None: + assert discovery._validated_payload({ + "schema_version": 1, + "updated_at": "2026-08-12T01:00:00Z", + "featured": featured, + }) is None diff --git a/tests/cli_apps/test_service.py b/tests/cli_apps/test_service.py index 5fd532898..7eb51f8db 100644 --- a/tests/cli_apps/test_service.py +++ b/tests/cli_apps/test_service.py @@ -204,6 +204,76 @@ def test_payload_uses_anygen_official_domain_for_logo(tmp_path: Path) -> None: assert app["logo_url"] == "https://www.google.com/s2/favicons?domain=anygen.io&sz=64" +def test_payload_uses_calibre_official_domain_for_logo(tmp_path: Path) -> None: + manager = _manager(tmp_path) + _write_cache( + manager._cache_path("harness"), + { + "meta": {"updated": "2026-04-16"}, + "clis": [ + { + "name": "calibre", + "display_name": "Calibre", + "entry_point": "cli-anything-calibre", + }, + ], + }, + ) + + app = next(app for app in manager.payload()["apps"] if app["name"] == "calibre") + + assert app["logo_url"] == ( + "https://www.google.com/s2/favicons?domain=calibre-ebook.com&sz=64" + ) + + +def test_payload_prefers_colored_official_homepage_logo(tmp_path: Path) -> None: + manager = _manager(tmp_path) + _write_cache( + manager._cache_path("harness"), + { + "meta": {"updated": "2026-04-16"}, + "clis": [ + { + "name": "blender", + "display_name": "Blender", + "homepage": "https://www.blender.org/features/", + "entry_point": "cli-anything-blender", + }, + ], + }, + ) + + app = next(app for app in manager.payload()["apps"] if app["name"] == "blender") + + assert app["logo_url"] == ( + "https://www.google.com/s2/favicons?domain=blender.org&sz=64" + ) + assert app["brand_color"] == "#E87D0D" + + +def test_payload_does_not_use_repository_host_as_the_app_logo(tmp_path: Path) -> None: + manager = _manager(tmp_path) + _write_cache( + manager._cache_path("harness"), + { + "meta": {"updated": "2026-04-16"}, + "clis": [ + { + "name": "gimp", + "display_name": "GIMP", + "homepage": "https://github.com/example/gimp-wrapper", + "entry_point": "cli-anything-gimp", + }, + ], + }, + ) + + app = next(app for app in manager.payload()["apps"] if app["name"] == "gimp") + + assert app["logo_url"] == "https://cdn.simpleicons.org/gimp/5C5543" + + def test_payload_resolves_obsidian_agent_cli_brand(tmp_path: Path) -> None: manager = _manager(tmp_path) _write_cache( diff --git a/tests/webui/test_settings_routes.py b/tests/webui/test_settings_routes.py index c048d8ec1..1c273eb50 100644 --- a/tests/webui/test_settings_routes.py +++ b/tests/webui/test_settings_routes.py @@ -89,6 +89,25 @@ async def test_mcp_list_serializes_local_runtime_failure_snapshot(tmp_path) -> N assert snapshot_calls == 1 +@pytest.mark.asyncio +async def test_apps_discovery_route_returns_registry(monkeypatch: pytest.MonkeyPatch) -> None: + payload = { + "schema_version": 1, + "updated_at": "2026-08-12T00:00:00Z", + "featured": ["mcp:github"], + } + discover = AsyncMock(return_value=payload) + monkeypatch.setattr("nanobot.webui.settings_routes.discovery_payload", discover) + request = SimpleNamespace(path="/api/settings/apps-discovery", headers=Headers()) + + response = await _router().dispatch(None, request, request.path) + + assert response is not None + assert response.status_code == 200 + assert json.loads(response.body) == payload + discover.assert_awaited_once() + + @pytest.mark.asyncio async def test_mcp_oauth_start_uses_gateway_callback_and_requires_api_auth(monkeypatch) -> None: config = SimpleNamespace( diff --git a/webui/src/components/settings/SettingsPage.tsx b/webui/src/components/settings/SettingsPage.tsx index 28d2d17a5..aaa8611fa 100644 --- a/webui/src/components/settings/SettingsPage.tsx +++ b/webui/src/components/settings/SettingsPage.tsx @@ -62,6 +62,7 @@ export function SettingsPage({ apiServiceAction, apiServiceError, apiServiceLoading, + appsDiscovery, appsKindFilter, appsQuery, automationAction, @@ -387,6 +388,7 @@ export function SettingsPage({ case "apps": return ( t(key, { defaultValue: fallback }); const filterOptions = [ - { value: "ready", label: tx("settings.apps.filterAll", "Ready") }, - { value: "cli", label: tx("settings.apps.filterCli", "Apps") }, - { value: "mcp", label: tx("settings.apps.filterMcp", "MCP") }, + { value: "discover", label: tx("settings.apps.discover", "Discover") }, + { value: "installed", label: tx("settings.apps.installed", "Installed") }, + { value: "all", label: tx("settings.apps.allApps", "All apps") }, ]; const normalizedQuery = query.trim().toLowerCase(); - const items: AppsCatalogItem[] = [ + const allItems: AppsCatalogItem[] = [ ...(cliApps?.apps ?? []).map((app) => ({ id: `cli:${app.name}`, kind: "cli" as const, app })), ...(mcpPresets?.presets ?? []).map((preset) => ({ id: `mcp:${preset.name}`, kind: "mcp" as const, preset, })), - ] - .filter((item) => { - if (normalizedQuery) return appsSearchText(item).includes(normalizedQuery); - if (filter === "ready") return appsReady(item); - if (filter === "cli") { - return item.kind === "cli" || item.preset.source === "agent-plugin"; - } - return item.kind === "mcp" && item.preset.source !== "agent-plugin"; - }) - .sort((left, right) => { - const rank = Number(!appsReady(left)) - Number(!appsReady(right)); - return rank || appsTitle(left).localeCompare(appsTitle(right)); - }); + ].sort((left, right) => appsTitle(left).localeCompare(appsTitle(right))); + const installedItems = allItems.filter(appsInstalled); + const featuredIds = discovery?.featured ?? DEFAULT_FEATURED_IDS; + const selectedFeatured = selectFeaturedApps(allItems, featuredIds); + const featuredCandidates = selectedFeatured.length + ? selectedFeatured + : selectFeaturedApps(allItems, DEFAULT_FEATURED_IDS); + const featuredBatchCount = Math.max(1, Math.ceil(featuredCandidates.length / FEATURED_BATCH_SIZE)); + const featuredBatchIndex = featuredBatch % featuredBatchCount; + const featuredItems = featuredCandidates.slice( + featuredBatchIndex * FEATURED_BATCH_SIZE, + (featuredBatchIndex + 1) * FEATURED_BATCH_SIZE, + ); + const visibleItems = normalizedQuery + ? allItems.filter((item) => appsSearchText(item).includes(normalizedQuery)) + : filter === "installed" + ? installedItems + : filter === "all" + ? allItems + : []; const focusedApp = cliFocusName ? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed) : null; @@ -212,23 +238,11 @@ export function AppsCatalogSettings({ (cliAppsLoading || mcpPresetsLoading) && !cliApps && !mcpPresets; - const cliAppCount = cliApps?.apps.length ?? 0; const emptyTitle = normalizedQuery ? tx("settings.apps.empty", "No tools match your search.") - : filter === "cli" - ? tx("settings.apps.emptyApps", "No apps available.") - : filter === "mcp" - ? tx("settings.apps.emptyIntegrations", "No MCP tools available.") - : tx("settings.apps.emptyReady", "No tools are ready yet."); - const emptyBrowseTarget: AppsKindFilter | null = normalizedQuery - ? null - : filter === "cli" - ? "mcp" - : filter === "mcp" - ? (cliAppCount ? "cli" : null) - : cliAppCount - ? "cli" - : "mcp"; + : filter === "installed" + ? tx("settings.apps.emptyInstalled", "No apps installed yet.") + : tx("settings.apps.emptyApps", "No apps available."); const statusMessage = cliError || mcpError || @@ -242,11 +256,58 @@ export function AppsCatalogSettings({ mcpOAuthFlow.completion_input, ) : ""; + const selectFilter = (value: AppsKindFilter) => { + if (query) onQueryChange(""); + onFilterChange(value); + }; + const renderItem = (item: AppsCatalogItem) => item.kind === "cli" ? ( + + ) : ( + + ); + const catalogGrid = (items: AppsCatalogItem[]) => ( +
+ {items.map((item) => ( +
+ {renderItem(item)} +
+ ))} +
+ ); return (
{oauthStatusAnnouncement}
-
-
+
+

+ {tx("settings.apps.description", "Add tools to nanobot, then @ them in chat.")} +

+
onFilterChange(value as AppsKindFilter)} + onChange={(value) => selectFilter(value as AppsKindFilter)} /> +
@@ -287,62 +357,22 @@ export function AppsCatalogSettings({ /> ) : null} -
-
- - {filter === "mcp" - ? tx("settings.apps.mcpTools", "MCP tools") - : tx("settings.apps.featured", "Tools")} - - - {items.length} - -
- {loading ? ( + {loading ? ( +
{tx("settings.apps.loading", "Loading Apps...")}
- ) : items.length ? ( -
- {items.map((item) => - item.kind === "cli" ? ( - - ) : ( - - ), - )} +
+ ) : normalizedQuery ? ( +
+
+ {tx("settings.apps.searchResults", "Search results")} + {visibleItems.length}
- ) : ( -
-

{emptyTitle}

- {normalizedQuery ? ( + {visibleItems.length ? catalogGrid(visibleItems) : ( +
+

{emptyTitle}

- ) : emptyBrowseTarget ? ( +
+ )} +
+ ) : filter === "discover" ? ( +
+
+
+ {tx("settings.apps.featured", "Featured")} + {featuredItems.length} +
+
+ {featuredBatchCount > 1 ? ( + + ) : null} - ) : ( -

- {tx( - "settings.apps.emptyIntegrationsHint", - "Add a custom MCP server below.", - )} -

- )} +
- )} -
- - {filter === "mcp" ? ( + {featuredItems.length ? catalogGrid(featuredItems) : ( +
+ {tx("settings.apps.emptyApps", "No apps available.")} +
+ )} +
+ ) : filter === "custom" ? ( - ) : null} + ) : ( +
+
+ + {filter === "installed" + ? tx("settings.apps.installed", "Installed") + : tx("settings.apps.allApps", "All apps")} + + {visibleItems.length} +
+ {visibleItems.length ? catalogGrid(visibleItems) : ( +
+

{emptyTitle}

+ {filter === "installed" ? ( + + ) : null} +
+ )} +
+ )}
); } @@ -1110,12 +1182,20 @@ function appsTitle(item: AppsCatalogItem): string { return item.kind === "cli" ? item.app.display_name : item.preset.display_name; } -function appsReady(item: AppsCatalogItem): boolean { +function appsInstalled(item: AppsCatalogItem): boolean { if (item.kind === "cli") return item.app.installed; - if (item.preset.enabled !== undefined) return item.preset.enabled; - return item.preset.installed && - item.preset.configured && - item.preset.runtime_status === "connected"; + return item.preset.installed; +} + +function selectFeaturedApps( + items: AppsCatalogItem[], + preferredIds: string[], +): AppsCatalogItem[] { + const byId = new Map(items.map((item) => [item.id, item])); + return preferredIds.flatMap((id) => { + const item = byId.get(id); + return item ? [item] : []; + }); } function appsSearchText(item: AppsCatalogItem): string { diff --git a/webui/src/components/settings/system/useSystemSettingsEffects.ts b/webui/src/components/settings/system/useSystemSettingsEffects.ts index e929df31b..6fdcdf36c 100644 --- a/webui/src/components/settings/system/useSystemSettingsEffects.ts +++ b/webui/src/components/settings/system/useSystemSettingsEffects.ts @@ -8,6 +8,7 @@ import { import type { SystemSettingsState } from "@/components/settings/system/useSystemSettingsState"; import { fetchApiService, + fetchAppsDiscovery, fetchAutomations, fetchCliApps, fetchMcpPresets, @@ -33,6 +34,7 @@ export function useSystemSettingsEffects({ setApiService, setApiServiceError, setApiServiceLoading, + setAppsDiscovery, setAutomations, setAutomationsError, setAutomationsLoading, @@ -47,6 +49,30 @@ export function useSystemSettingsEffects({ setNanobotFeaturesLoading, } = state; + useEffect(() => { + if (activeSection !== "apps") return; + let cancelled = false; + let retry: number | null = null; + let retryCount = 0; + const load = () => { + fetchAppsDiscovery(getToken()) + .then((payload) => { + if (cancelled) return; + setAppsDiscovery(payload); + if (payload.refresh_pending && retryCount < 3) { + retryCount += 1; + retry = window.setTimeout(load, CLI_APPS_REFRESH_RETRY_MS); + } + }) + .catch(() => undefined); + }; + load(); + return () => { + cancelled = true; + if (retry !== null) window.clearTimeout(retry); + }; + }, [activeSection, getToken, setAppsDiscovery]); + useEffect(() => { if (activeSection !== "apps") return; let cancelled = false; diff --git a/webui/src/components/settings/system/useSystemSettingsState.ts b/webui/src/components/settings/system/useSystemSettingsState.ts index 33bffdc57..0709ac608 100644 --- a/webui/src/components/settings/system/useSystemSettingsState.ts +++ b/webui/src/components/settings/system/useSystemSettingsState.ts @@ -11,6 +11,7 @@ import { } from "@/components/settings/system/AppsSettings"; import type { ApiServicePayload, + AppsDiscoveryPayload, AutomationsPayload, CliAppsPayload, McpOAuthFlowPayload, @@ -21,6 +22,7 @@ import type { } from "@/lib/types"; export function useSystemSettingsState() { + const [appsDiscovery, setAppsDiscovery] = useState(null); const [cliApps, setCliApps] = useState(null); const [nanobotFeatures, setNanobotFeatures] = useState(null); const [mcpPresets, setMcpPresets] = useState(null); @@ -54,7 +56,7 @@ export function useSystemSettingsState() { const [cliAppsError, setCliAppsError] = useState(null); const [nanobotFeaturesError, setNanobotFeaturesError] = useState(null); const [cliAppsFocusName, setCliAppsFocusName] = useState(null); - const [appsKindFilter, setAppsKindFilter] = useState("cli"); + const [appsKindFilter, setAppsKindFilter] = useState("discover"); const [mcpMessage, setMcpMessage] = useState(null); const [mcpError, setMcpError] = useState(null); const [automationsError, setAutomationsError] = useState(null); @@ -72,6 +74,7 @@ export function useSystemSettingsState() { apiServiceAction, apiServiceError, apiServiceLoading, + appsDiscovery, appsKindFilter, appsQuery, automationAction, @@ -115,6 +118,7 @@ export function useSystemSettingsState() { setApiServiceAction, setApiServiceError, setApiServiceLoading, + setAppsDiscovery, setAppsKindFilter, setAppsQuery, setAutomationAction, diff --git a/webui/src/components/settings/useSettingsController.ts b/webui/src/components/settings/useSettingsController.ts index 994d50bd0..d1c8a8cca 100644 --- a/webui/src/components/settings/useSettingsController.ts +++ b/webui/src/components/settings/useSettingsController.ts @@ -110,7 +110,7 @@ export function useSettingsController({ } = capabilityState; const systemState = useSystemSettingsState(); const { - apiService, apiServiceAction, apiServiceError, apiServiceLoading, appsKindFilter, appsQuery, + apiService, apiServiceAction, apiServiceError, apiServiceLoading, appsDiscovery, appsKindFilter, appsQuery, automationAction, automationPendingDelete, automationPendingEdit, automations, automationsError, automationsFilter, automationsLoading, automationsQuery, automationsSort, channelsQuery, cliApps, cliAppsAction, cliAppsError, cliAppsFocusName, cliAppsLoading, @@ -459,6 +459,7 @@ export function useSettingsController({ apiServiceAction, apiServiceError, apiServiceLoading, + appsDiscovery, appsKindFilter, appsQuery, automationAction, diff --git a/webui/src/i18n/locales/en/common.json b/webui/src/i18n/locales/en/common.json index a66379d35..34adf37ab 100644 --- a/webui/src/i18n/locales/en/common.json +++ b/webui/src/i18n/locales/en/common.json @@ -241,7 +241,7 @@ "activityMode": "Choose how much agent activity chrome to show by default.", "fileEditDisplay": "Choose whether file edit activity opens as line counts or a diff.", "codeWrap": "Keep long code lines readable on smaller screens.", - "brandLogos": "Show third-party provider and CLI logos in Settings.", + "brandLogos": "Load third-party brand logos from external icon services. Turn this off to use local initials.", "maxResults": "Results returned by each web_search call.", "timeout": "Seconds before a search provider request times out.", "jinaReader": "Use Jina Reader for web_fetch when available.", @@ -557,9 +557,6 @@ "capabilityOpenAISearch": "OpenAI web search", "capabilityOpenAISearchHelp": "Allow compatible Responses API models to search the web. Search activity appears in chat." }, - "legal": { - "thirdPartyBrands": "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement." - }, "image": { "selectProvider": "Select provider", "selectAspect": "Select aspect", @@ -606,6 +603,14 @@ }, "apps": { "description": "Add tools to nanobot, then @ them in chat.", + "discover": "Discover", + "installed": "Installed", + "allApps": "All apps", + "addCustom": "Add custom", + "emptyInstalled": "No apps installed yet.", + "searchResults": "Search results", + "browseAll": "Browse all", + "nextFeatured": "Show another", "cliLabel": "App", "mcpLabel": "MCP", "channelLabel": "Channel", @@ -617,7 +622,7 @@ "enabledSummary": "{{count}} ready", "caption": "{{cli}} apps · {{mcp}} MCP tools", "searchPlaceholder": "Search tools", - "featured": "Tools", + "featured": "Featured", "mcpTools": "MCP tools", "loading": "Loading Apps...", "empty": "No tools match your search.", diff --git a/webui/src/i18n/locales/es/common.json b/webui/src/i18n/locales/es/common.json index b5ccab76c..c3c2aa5a8 100644 --- a/webui/src/i18n/locales/es/common.json +++ b/webui/src/i18n/locales/es/common.json @@ -203,7 +203,7 @@ "currentModel": "Se usa para nuevas respuestas.", "selectedModelProvider": "Definido por el modelo seleccionado.", "selectedModelValue": "Definido por el modelo seleccionado.", - "brandLogos": "Muestra logos de proveedores de terceros y CLI en Ajustes.", + "brandLogos": "Carga logotipos de marcas de terceros desde servicios de iconos externos. Desactívalo para usar iniciales locales.", "cliAppsCatalog": "Instala solo adaptadores CLI de aplicaciones que nanobot puede ejecutar localmente; las aplicaciones nativas no se modifican.", "cliAppsFilter": "Busca por aplicación, categoría o capacidad.", "logs": "Abre la carpeta de registros del motor nativo.", @@ -588,11 +588,16 @@ "title": "Observabilidad", "configured": "Las credenciales de trazas están disponibles para nanobot.", "environment": "Configura LANGFUSE_SECRET_KEY y LANGFUSE_PUBLIC_KEY y reinicia nanobot.", "enable": "Habilitar soporte de trazas" }, - "legal": { - "thirdPartyBrands": "Los nombres, logotipos y marcas de productos pertenecen a sus respectivos propietarios. Su uso es solo identificativo y no implica respaldo." - }, "apps": { "description": "Añade herramientas a nanobot y luego úsalas con @ en el chat.", + "discover": "Descubrir", + "installed": "Instaladas", + "allApps": "Todas las apps", + "addCustom": "Añadir personalizada", + "emptyInstalled": "Aún no hay apps instaladas.", + "searchResults": "Resultados de búsqueda", + "browseAll": "Ver todas", + "nextFeatured": "Mostrar otras", "cliLabel": "Aplicación", "mcpLabel": "MCP", "channelLabel": "Canal", @@ -604,7 +609,7 @@ "enabledSummary": "{{count}} listos", "caption": "{{cli}} aplicaciones · {{mcp}} herramientas MCP", "searchPlaceholder": "Buscar aplicaciones", - "featured": "Herramientas", + "featured": "Destacadas", "mcpTools": "Herramientas MCP", "loading": "Cargando aplicaciones...", "empty": "Ninguna herramienta coincide con tu búsqueda.", diff --git a/webui/src/i18n/locales/fr/common.json b/webui/src/i18n/locales/fr/common.json index a305e8de1..2a01f3956 100644 --- a/webui/src/i18n/locales/fr/common.json +++ b/webui/src/i18n/locales/fr/common.json @@ -203,7 +203,7 @@ "currentModel": "Utilisée pour les nouvelles réponses.", "selectedModelProvider": "Défini par le modèle sélectionné.", "selectedModelValue": "Défini par le modèle sélectionné.", - "brandLogos": "Affiche les logos de fournisseurs tiers et CLI dans les Réglages.", + "brandLogos": "Charge les logos de marques tierces depuis des services d’icônes externes. Désactivez cette option pour utiliser des initiales locales.", "cliAppsCatalog": "Installe uniquement les adaptateurs CLI d’applications que nanobot peut exécuter localement ; les applications natives restent inchangées.", "cliAppsFilter": "Recherchez par application, catégorie ou capacité.", "logs": "Ouvre le dossier des journaux du moteur natif.", @@ -587,11 +587,16 @@ "title": "Observabilité", "configured": "Les identifiants de traçage sont disponibles pour nanobot.", "environment": "Définissez LANGFUSE_SECRET_KEY et LANGFUSE_PUBLIC_KEY, puis redémarrez nanobot.", "enable": "Activer le traçage" }, - "legal": { - "thirdPartyBrands": "Les noms, logos et marques de produits appartiennent à leurs propriétaires respectifs. Leur utilisation sert uniquement à l'identification et n'implique aucune approbation." - }, "apps": { "description": "Ajoutez des outils à nanobot, puis utilisez-les avec @ dans le chat.", + "discover": "Découvrir", + "installed": "Installées", + "allApps": "Toutes les apps", + "addCustom": "Ajouter une app", + "emptyInstalled": "Aucune app installée pour le moment.", + "searchResults": "Résultats de recherche", + "browseAll": "Tout afficher", + "nextFeatured": "Voir d’autres", "cliLabel": "Application", "mcpLabel": "MCP", "channelLabel": "Canal", @@ -603,7 +608,7 @@ "enabledSummary": "{{count}} prêts", "caption": "{{cli}} applications · {{mcp}} outils MCP", "searchPlaceholder": "Rechercher des applications", - "featured": "Outils", + "featured": "À la une", "mcpTools": "Outils MCP", "loading": "Chargement des applications...", "empty": "Aucun outil ne correspond à votre recherche.", diff --git a/webui/src/i18n/locales/id/common.json b/webui/src/i18n/locales/id/common.json index 0f1eecb22..ae7a2e0de 100644 --- a/webui/src/i18n/locales/id/common.json +++ b/webui/src/i18n/locales/id/common.json @@ -203,7 +203,7 @@ "currentModel": "Digunakan untuk balasan baru.", "selectedModelProvider": "Ditentukan oleh model yang dipilih.", "selectedModelValue": "Ditentukan oleh model yang dipilih.", - "brandLogos": "Tampilkan logo penyedia pihak ketiga dan CLI di Pengaturan.", + "brandLogos": "Muat logo merek pihak ketiga dari layanan ikon eksternal. Nonaktifkan untuk menggunakan inisial lokal.", "cliAppsCatalog": "Instal hanya adaptor CLI aplikasi yang dapat dijalankan nanobot secara lokal; aplikasi asli tidak diubah.", "cliAppsFilter": "Cari berdasarkan aplikasi, kategori, atau kemampuan.", "logs": "Buka folder log mesin asli.", @@ -587,11 +587,16 @@ "title": "Observabilitas", "configured": "Kredensial tracing tersedia untuk nanobot.", "environment": "Atur LANGFUSE_SECRET_KEY dan LANGFUSE_PUBLIC_KEY, lalu mulai ulang nanobot.", "enable": "Aktifkan dukungan tracing" }, - "legal": { - "thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan." - }, "apps": { "description": "Tambahkan alat ke nanobot, lalu gunakan dengan @ di chat.", + "discover": "Temukan", + "installed": "Terpasang", + "allApps": "Semua aplikasi", + "addCustom": "Tambah kustom", + "emptyInstalled": "Belum ada aplikasi yang terpasang.", + "searchResults": "Hasil pencarian", + "browseAll": "Lihat semua", + "nextFeatured": "Tampilkan lainnya", "cliLabel": "Aplikasi", "mcpLabel": "MCP", "channelLabel": "Kanal", @@ -603,7 +608,7 @@ "enabledSummary": "{{count}} siap", "caption": "{{cli}} aplikasi · {{mcp}} alat MCP", "searchPlaceholder": "Cari aplikasi", - "featured": "Alat", + "featured": "Unggulan", "mcpTools": "Alat MCP", "loading": "Memuat aplikasi...", "empty": "Tidak ada alat yang cocok dengan pencarian Anda.", diff --git a/webui/src/i18n/locales/ja/common.json b/webui/src/i18n/locales/ja/common.json index 67022ae30..63a583fa3 100644 --- a/webui/src/i18n/locales/ja/common.json +++ b/webui/src/i18n/locales/ja/common.json @@ -203,7 +203,7 @@ "currentModel": "新しい返信に使用します。", "selectedModelProvider": "選択したモデルによって設定されます。", "selectedModelValue": "選択したモデルによって設定されます。", - "brandLogos": "設定で第三者プロバイダーと CLI のロゴを表示します。", + "brandLogos": "外部のアイコンサービスからサードパーティのブランドロゴを読み込みます。オフにするとローカルのイニシャルアイコンを使用します。", "cliAppsCatalog": "nanobot がローカルで実行できるアプリ CLI アダプターだけをインストールします。ネイティブアプリは変更しません。", "cliAppsFilter": "アプリ、カテゴリ、機能で検索します。", "logs": "ネイティブエンジンのログフォルダーを開きます。", @@ -587,11 +587,16 @@ "title": "可観測性", "configured": "nanobot がトレース認証情報を利用できます。", "environment": "LANGFUSE_SECRET_KEY と LANGFUSE_PUBLIC_KEY を設定して nanobot を再起動してください。", "enable": "トレースサポートを有効化" }, - "legal": { - "thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。" - }, "apps": { "description": "nanobot にツールを追加し、チャットで @ を付けて使用できます。", + "discover": "見つける", + "installed": "インストール済み", + "allApps": "すべてのアプリ", + "addCustom": "カスタム追加", + "emptyInstalled": "インストール済みのアプリはありません。", + "searchResults": "検索結果", + "browseAll": "すべて見る", + "nextFeatured": "ほかを見る", "cliLabel": "アプリ", "mcpLabel": "MCP", "channelLabel": "チャンネル", @@ -603,7 +608,7 @@ "enabledSummary": "{{count}} 件使用可能", "caption": "アプリ {{cli}} 件 · MCP ツール {{mcp}} 件", "searchPlaceholder": "アプリを検索", - "featured": "ツール", + "featured": "おすすめ", "mcpTools": "MCP ツール", "loading": "アプリを読み込み中...", "empty": "検索条件に一致するツールはありません。", diff --git a/webui/src/i18n/locales/ko/common.json b/webui/src/i18n/locales/ko/common.json index 11ee620f6..24313d0d1 100644 --- a/webui/src/i18n/locales/ko/common.json +++ b/webui/src/i18n/locales/ko/common.json @@ -203,7 +203,7 @@ "currentModel": "새 응답에 사용됩니다.", "selectedModelProvider": "선택한 모델에 의해 설정됩니다.", "selectedModelValue": "선택한 모델에 의해 설정됩니다.", - "brandLogos": "설정에서 타사 제공자와 CLI 로고를 표시합니다.", + "brandLogos": "외부 아이콘 서비스에서 타사 브랜드 로고를 불러옵니다. 끄면 로컬 이니셜 아이콘을 사용합니다.", "cliAppsCatalog": "nanobot이 로컬에서 실행할 수 있는 앱 CLI 어댑터만 설치합니다. 네이티브 앱은 변경하지 않습니다.", "cliAppsFilter": "앱, 카테고리 또는 기능으로 검색합니다.", "logs": "네이티브 엔진 로그 폴더를 엽니다.", @@ -587,11 +587,16 @@ "title": "관측성", "configured": "nanobot이 추적 자격 증명을 사용할 수 있습니다.", "environment": "LANGFUSE_SECRET_KEY와 LANGFUSE_PUBLIC_KEY를 설정한 뒤 nanobot을 다시 시작하세요.", "enable": "추적 지원 활성화" }, - "legal": { - "thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다." - }, "apps": { "description": "nanobot에 도구를 추가한 뒤 채팅에서 @로 사용하세요.", + "discover": "둘러보기", + "installed": "설치됨", + "allApps": "모든 앱", + "addCustom": "사용자 지정 추가", + "emptyInstalled": "설치된 앱이 없습니다.", + "searchResults": "검색 결과", + "browseAll": "모두 보기", + "nextFeatured": "다른 추천 보기", "cliLabel": "앱", "mcpLabel": "MCP", "channelLabel": "채널", @@ -603,7 +608,7 @@ "enabledSummary": "{{count}}개 사용 가능", "caption": "앱 {{cli}}개 · MCP 도구 {{mcp}}개", "searchPlaceholder": "앱 검색", - "featured": "도구", + "featured": "추천", "mcpTools": "MCP 도구", "loading": "앱을 불러오는 중...", "empty": "검색과 일치하는 도구가 없습니다.", diff --git a/webui/src/i18n/locales/pt-BR/common.json b/webui/src/i18n/locales/pt-BR/common.json index e34e61551..35a903114 100644 --- a/webui/src/i18n/locales/pt-BR/common.json +++ b/webui/src/i18n/locales/pt-BR/common.json @@ -241,7 +241,7 @@ "activityMode": "Escolha quanto detalhe de atividade do agente é exibido por padrão.", "fileEditDisplay": "Escolha se a atividade de edição de arquivo é exibida como contagem de linhas ou como diferenças.", "codeWrap": "Mantém linhas longas de código legíveis em telas menores.", - "brandLogos": "Mostra logotipos de provedores terceiros e de CLIs em Configurações.", + "brandLogos": "Carrega logotipos de marcas de terceiros por serviços externos de ícones. Desative para usar iniciais locais.", "maxResults": "Resultados retornados por cada chamada de web_search.", "timeout": "Segundos antes de uma requisição de busca expirar.", "jinaReader": "Usa o Jina Reader para web_fetch quando disponível.", @@ -557,9 +557,6 @@ "capabilityOpenAISearch": "Pesquisa web da OpenAI", "capabilityOpenAISearchHelp": "Permite que modelos compatíveis com a Responses API pesquisem na web. A atividade de pesquisa aparece no chat." }, - "legal": { - "thirdPartyBrands": "Nomes de produtos, logotipos e marcas são propriedades de seus respectivos donos. O uso é apenas para identificação e não implica endosso." - }, "image": { "selectProvider": "Selecionar provedor", "selectAspect": "Selecionar proporção", @@ -606,6 +603,14 @@ }, "apps": { "description": "Adicione ferramentas ao nanobot e mencione-as com @ na conversa.", + "discover": "Descobrir", + "installed": "Instalados", + "allApps": "Todos os apps", + "addCustom": "Adicionar personalizado", + "emptyInstalled": "Nenhum app instalado ainda.", + "searchResults": "Resultados da busca", + "browseAll": "Ver todos", + "nextFeatured": "Mostrar outros", "cliLabel": "Aplicativo", "mcpLabel": "MCP", "channelLabel": "Canal", @@ -617,7 +622,7 @@ "enabledSummary": "{{count}} prontos", "caption": "{{cli}} aplicativos · {{mcp}} ferramentas MCP", "searchPlaceholder": "Buscar ferramentas", - "featured": "Ferramentas", + "featured": "Destaques", "mcpTools": "Ferramentas MCP", "loading": "Carregando aplicativos...", "empty": "Nenhuma ferramenta corresponde à sua busca.", diff --git a/webui/src/i18n/locales/vi/common.json b/webui/src/i18n/locales/vi/common.json index 875280a25..7217558a0 100644 --- a/webui/src/i18n/locales/vi/common.json +++ b/webui/src/i18n/locales/vi/common.json @@ -203,7 +203,7 @@ "currentModel": "Dùng cho các phản hồi mới.", "selectedModelProvider": "Được đặt bởi mô hình đã chọn.", "selectedModelValue": "Được đặt bởi mô hình đã chọn.", - "brandLogos": "Hiển thị logo nhà cung cấp bên thứ ba và CLI trong Cài đặt.", + "brandLogos": "Tải logo thương hiệu của bên thứ ba từ dịch vụ biểu tượng bên ngoài. Tắt để dùng chữ cái đại diện cục bộ.", "cliAppsCatalog": "Chỉ cài đặt các bộ chuyển đổi CLI ứng dụng mà nanobot có thể chạy cục bộ; ứng dụng gốc không bị thay đổi.", "cliAppsFilter": "Tìm theo ứng dụng, danh mục hoặc khả năng.", "logs": "Mở thư mục nhật ký của bộ máy gốc.", @@ -587,11 +587,16 @@ "title": "Khả năng quan sát", "configured": "Thông tin xác thực tracing đã sẵn sàng cho nanobot.", "environment": "Đặt LANGFUSE_SECRET_KEY và LANGFUSE_PUBLIC_KEY rồi khởi động lại nanobot.", "enable": "Bật hỗ trợ tracing" }, - "legal": { - "thirdPartyBrands": "Tên sản phẩm, logo và thương hiệu thuộc về chủ sở hữu tương ứng. Việc sử dụng chỉ nhằm nhận diện và không ngụ ý được xác nhận." - }, "apps": { "description": "Thêm công cụ vào nanobot, sau đó dùng @ trong cuộc trò chuyện.", + "discover": "Khám phá", + "installed": "Đã cài đặt", + "allApps": "Tất cả ứng dụng", + "addCustom": "Thêm tùy chỉnh", + "emptyInstalled": "Chưa có ứng dụng nào được cài đặt.", + "searchResults": "Kết quả tìm kiếm", + "browseAll": "Xem tất cả", + "nextFeatured": "Xem nhóm khác", "cliLabel": "Ứng dụng", "mcpLabel": "MCP", "channelLabel": "Kênh", @@ -603,7 +608,7 @@ "enabledSummary": "{{count}} sẵn sàng", "caption": "{{cli}} ứng dụng · {{mcp}} công cụ MCP", "searchPlaceholder": "Tìm ứng dụng", - "featured": "Công cụ", + "featured": "Nổi bật", "mcpTools": "Công cụ MCP", "loading": "Đang tải ứng dụng...", "empty": "Không có công cụ phù hợp với tìm kiếm của bạn.", diff --git a/webui/src/i18n/locales/zh-CN/common.json b/webui/src/i18n/locales/zh-CN/common.json index 95ec90dd5..63dce1ef7 100644 --- a/webui/src/i18n/locales/zh-CN/common.json +++ b/webui/src/i18n/locales/zh-CN/common.json @@ -241,7 +241,7 @@ "activityMode": "选择默认显示多少智能体活动详情。", "fileEditDisplay": "选择文件编辑活动默认显示行数还是差异。", "codeWrap": "让长代码行在小屏幕上也易读。", - "brandLogos": "在设置中显示第三方提供商和 CLI 图标。", + "brandLogos": "从外部图标服务加载第三方品牌 Logo;关闭后使用本地首字母图标。", "maxResults": "每次 web_search 调用返回的结果数。", "timeout": "搜索提供商请求超时前等待的秒数。", "jinaReader": "可用时为 web_fetch 使用 Jina Reader。", @@ -557,9 +557,6 @@ "capabilityOpenAISearch": "OpenAI 联网搜索", "capabilityOpenAISearchHelp": "允许兼容 Responses API 的模型搜索网络,并在对话中展示搜索过程。" }, - "legal": { - "thirdPartyBrands": "产品名称、Logo 和品牌归各自所有者所有;此处仅用于识别,不代表背书或合作。" - }, "image": { "selectProvider": "选择提供商", "selectAspect": "选择比例", @@ -606,6 +603,14 @@ }, "apps": { "description": "将工具接入 nanobot,然后在对话中通过 @ 调用。", + "discover": "发现", + "installed": "已安装", + "allApps": "全部应用", + "addCustom": "自定义接入", + "emptyInstalled": "还没有安装应用。", + "searchResults": "搜索结果", + "browseAll": "浏览全部", + "nextFeatured": "换一批", "cliLabel": "应用", "mcpLabel": "MCP", "channelLabel": "渠道", @@ -617,7 +622,7 @@ "enabledSummary": "{{count}} 个可用", "caption": "{{cli}} 个应用 · {{mcp}} 个 MCP 工具", "searchPlaceholder": "搜索工具", - "featured": "工具", + "featured": "精选", "mcpTools": "MCP 工具", "loading": "正在加载应用...", "empty": "没有与搜索条件匹配的工具。", diff --git a/webui/src/i18n/locales/zh-TW/common.json b/webui/src/i18n/locales/zh-TW/common.json index 69ae9fd42..25b65af8d 100644 --- a/webui/src/i18n/locales/zh-TW/common.json +++ b/webui/src/i18n/locales/zh-TW/common.json @@ -203,7 +203,7 @@ "currentModel": "用於新的回覆。", "selectedModelProvider": "由選取的模型決定。", "selectedModelValue": "由選取的模型決定。", - "brandLogos": "在設定中顯示第三方供應商與 CLI 圖示。", + "brandLogos": "從外部圖示服務載入第三方品牌 Logo;關閉後使用本機首字母圖示。", "cliAppsCatalog": "只安裝 nanobot 可在本機執行的應用程式專用 CLI 轉接器;不會改動原生應用程式。", "cliAppsFilter": "依應用程式、類別或功能搜尋。", "logs": "開啟原生引擎日誌資料夾。", @@ -587,11 +587,16 @@ "title": "可觀測性", "configured": "nanobot 已偵測到追蹤憑證。", "environment": "設定 LANGFUSE_SECRET_KEY 和 LANGFUSE_PUBLIC_KEY 後重新啟動 nanobot。", "enable": "啟用追蹤支援" }, - "legal": { - "thirdPartyBrands": "產品名稱、Logo 與品牌均為各自擁有者的財產。僅供識別之用,不代表任何形式的背書。" - }, "apps": { "description": "將工具新增至 nanobot,再於聊天中使用 @ 指定工具。", + "discover": "探索", + "installed": "已安裝", + "allApps": "全部應用", + "addCustom": "自訂接入", + "emptyInstalled": "尚未安裝應用。", + "searchResults": "搜尋結果", + "browseAll": "瀏覽全部", + "nextFeatured": "換一批", "cliLabel": "應用程式", "mcpLabel": "MCP", "channelLabel": "通訊管道", @@ -603,7 +608,7 @@ "enabledSummary": "{{count}} 個就緒", "caption": "{{cli}} 個應用程式 · {{mcp}} 個 MCP 工具", "searchPlaceholder": "搜尋工具", - "featured": "工具", + "featured": "精選", "mcpTools": "MCP 工具", "loading": "正在載入應用程式…", "empty": "沒有符合搜尋條件的工具。", diff --git a/webui/src/lib/api.ts b/webui/src/lib/api.ts index 3147a7bb9..999644570 100644 --- a/webui/src/lib/api.ts +++ b/webui/src/lib/api.ts @@ -1,5 +1,6 @@ import type { ApiServicePayload, + AppsDiscoveryPayload, AutomationsPayload, AutomationUpdatePayload, ChannelConfigurePayload, @@ -491,6 +492,18 @@ export async function fetchCliApps( ); } +export async function fetchAppsDiscovery( + token: string, + base: string = "", +): Promise { + return request( + `${base}/api/settings/apps-discovery`, + token, + undefined, + API_READ_TIMEOUT_MS, + ); +} + export async function fetchInstalledCliApps( token: string, base: string = "", diff --git a/webui/src/lib/local-preferences.ts b/webui/src/lib/local-preferences.ts index 53e3c4836..d25af354e 100644 --- a/webui/src/lib/local-preferences.ts +++ b/webui/src/lib/local-preferences.ts @@ -12,12 +12,15 @@ export interface LocalPreferences { export const LOCAL_PREFS_STORAGE_KEY = "nanobot-webui.settings-preferences"; export const LOCAL_PREFS_CHANGED_EVENT = "nanobot-webui.local-preferences-changed"; +export const LOCAL_PREFS_VERSION = 1; + +type StoredLocalPreferences = Partial & { version?: number }; export const DEFAULT_LOCAL_PREFS: LocalPreferences = { density: "comfortable", activityMode: "auto", codeWrap: true, - brandLogos: false, + brandLogos: true, fileEditDisplayMode: "summary", }; @@ -29,12 +32,12 @@ export function readLocalPreferences(): LocalPreferences { try { const raw = window.localStorage.getItem(LOCAL_PREFS_STORAGE_KEY); if (!raw) return DEFAULT_LOCAL_PREFS; - const parsed = JSON.parse(raw) as Partial; + const parsed = JSON.parse(raw) as StoredLocalPreferences; return { density: parsed.density === "compact" ? "compact" : "comfortable", activityMode: parsed.activityMode === "expanded" ? "expanded" : "auto", codeWrap: parsed.codeWrap !== false, - brandLogos: parsed.brandLogos === true, + brandLogos: parsed.version === undefined ? true : parsed.brandLogos !== false, fileEditDisplayMode: normalizeFileEditDisplayMode(parsed.fileEditDisplayMode), }; } catch { @@ -44,7 +47,10 @@ export function readLocalPreferences(): LocalPreferences { export function writeLocalPreferences(preferences: LocalPreferences): void { try { - window.localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify(preferences)); + window.localStorage.setItem( + LOCAL_PREFS_STORAGE_KEY, + JSON.stringify({ version: LOCAL_PREFS_VERSION, ...preferences }), + ); } catch { // Browser-only preferences should never block settings. } diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index b9f6910e3..396dba9fa 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -817,6 +817,13 @@ export interface CliAppsPayload { }; } +export interface AppsDiscoveryPayload { + schema_version: number; + updated_at: string; + featured: string[]; + refresh_pending?: boolean; +} + export interface NanobotFeatureInfo { name: string; display_name: string; diff --git a/webui/src/tests/app-layout.test.tsx b/webui/src/tests/app-layout.test.tsx index 2ff477b02..49e852728 100644 --- a/webui/src/tests/app-layout.test.tsx +++ b/webui/src/tests/app-layout.test.tsx @@ -2689,7 +2689,7 @@ describe("App layout", () => { fireEvent.click(appsButton); expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument(); - expect(screen.queryByText("Add tools to nanobot, then @ them in chat.")).not.toBeInTheDocument(); + expect(screen.getByText("Add tools to nanobot, then @ them in chat.")).toBeInTheDocument(); expect(screen.getByRole("navigation", { name: "Sidebar navigation" })).toBeInTheDocument(); expect(screen.queryByRole("navigation", { name: "Settings sections" })).not.toBeInTheDocument(); expect(within(sidebar).getByRole("button", { name: "Apps" })).toHaveAttribute( diff --git a/webui/src/tests/local-preferences.test.ts b/webui/src/tests/local-preferences.test.ts new file mode 100644 index 000000000..34d0ca3f8 --- /dev/null +++ b/webui/src/tests/local-preferences.test.ts @@ -0,0 +1,48 @@ +import { beforeEach, describe, expect, it } from "vitest"; + +import { + DEFAULT_LOCAL_PREFS, + LOCAL_PREFS_STORAGE_KEY, + LOCAL_PREFS_VERSION, + readLocalPreferences, + writeLocalPreferences, +} from "@/lib/local-preferences"; + +describe("local preferences", () => { + beforeEach(() => localStorage.clear()); + + it("shows brand logos by default", () => { + expect(DEFAULT_LOCAL_PREFS.brandLogos).toBe(true); + expect(readLocalPreferences().brandLogos).toBe(true); + }); + + it("preserves an explicit brand-logo opt-out", () => { + localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify({ + version: LOCAL_PREFS_VERSION, + brandLogos: false, + })); + + expect(readLocalPreferences().brandLogos).toBe(false); + }); + + it("migrates the old auto-persisted opt-out to visible logos", () => { + localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify({ brandLogos: false })); + + expect(readLocalPreferences().brandLogos).toBe(true); + }); + + it("enables brand logos for legacy preferences without the field", () => { + localStorage.setItem(LOCAL_PREFS_STORAGE_KEY, JSON.stringify({ density: "compact" })); + + expect(readLocalPreferences().brandLogos).toBe(true); + }); + + it("versions newly written preferences", () => { + writeLocalPreferences({ ...DEFAULT_LOCAL_PREFS, brandLogos: false }); + + expect(JSON.parse(localStorage.getItem(LOCAL_PREFS_STORAGE_KEY) ?? "{}")).toMatchObject({ + version: LOCAL_PREFS_VERSION, + brandLogos: false, + }); + }); +}); diff --git a/webui/src/tests/settings-apps-oauth.test.tsx b/webui/src/tests/settings-apps-oauth.test.tsx index 54e2baa72..ebad80129 100644 --- a/webui/src/tests/settings-apps-oauth.test.tsx +++ b/webui/src/tests/settings-apps-oauth.test.tsx @@ -103,8 +103,7 @@ describe("SettingsView Apps catalog", () => { vi.stubGlobal("open", open); renderSettingsView({ initialSection: "apps" }); - fireEvent.click(await screen.findByRole("button", { name: "MCP" })); - expect(screen.getByText("MCP tools")).toBeInTheDocument(); + fireEvent.click(await screen.findByRole("button", { name: "All apps" })); const connectButton = await screen.findByRole("button", { name: "Connect Xmind" }); expect(connectButton).toHaveTextContent("Connect"); fireEvent.click(connectButton); @@ -167,17 +166,12 @@ describe("SettingsView Apps catalog", () => { requestMutationMock.mockRejectedValueOnce(new Error("Stopped after request assertion")); renderSettingsView({ initialSection: "apps" }); - fireEvent.click(await screen.findByRole("button", { name: "Ready" })); - expect(await screen.findByText("No tools are ready yet.")).toBeInTheDocument(); - expect(screen.queryByRole("heading", { name: "Xmind" })).not.toBeInTheDocument(); - - fireEvent.click(await screen.findByRole("button", { name: "MCP" })); - + fireEvent.click(await screen.findByRole("button", { name: "Installed" })); const heading = await screen.findByRole("heading", { name: "Xmind" }); const row = heading.closest("article"); expect(row).not.toBeNull(); - expect(row?.parentElement).toHaveClass("xl:grid-cols-2"); - expect(within(row as HTMLElement).queryByText("MCP")).not.toBeInTheDocument(); + expect(row?.parentElement?.parentElement).toHaveClass("xl:grid-cols-2"); + expect(within(row as HTMLElement).getByText("MCP")).toBeInTheDocument(); const failed = within(row as HTMLElement).getByText("Connection failed."); expect(failed.closest("button")).toBeNull(); expect(failed.closest("p")?.querySelector(".lucide-triangle-alert")).not.toBeNull(); @@ -240,7 +234,7 @@ describe("SettingsView Apps catalog", () => { vi.stubGlobal("fetch", fetchMock); renderSettingsView({ initialSection: "apps" }); - fireEvent.click(await screen.findByRole("button", { name: "MCP" })); + fireEvent.click(await screen.findByRole("button", { name: "All apps" })); expect(await screen.findByRole("button", { name: "Xmind: Connecting…" })) .toHaveTextContent("Connecting…"); @@ -251,7 +245,7 @@ describe("SettingsView Apps catalog", () => { )).toHaveTextContent("Connected."); expect(mcpPresetRequests).toBe(2); - fireEvent.click(screen.getByRole("button", { name: "Ready" })); + fireEvent.click(screen.getByRole("button", { name: "Installed" })); expect(await screen.findByRole("heading", { name: "Xmind" })).toBeInTheDocument(); }); @@ -296,7 +290,7 @@ describe("SettingsView Apps catalog", () => { }); renderSettingsView({ initialSection: "apps" }); - fireEvent.click(await screen.findByRole("button", { name: "MCP" })); + fireEvent.click(await screen.findByRole("button", { name: "All apps" })); expect(await screen.findByText("Connection failed.")).toBeInTheDocument(); fireEvent.click(screen.getByRole("button", { name: "Reconnect team-docs" })); @@ -309,7 +303,7 @@ describe("SettingsView Apps catalog", () => { const connected = await screen.findByRole("button", { name: "team-docs: Connected." }); expect(connected).toHaveTextContent("Connected."); expect(connected.querySelector(".lucide-check")).not.toBeNull(); - fireEvent.click(screen.getByRole("button", { name: "Ready" })); + fireEvent.click(screen.getByRole("button", { name: "Installed" })); const readyHeading = await screen.findByRole("heading", { name: "team-docs" }); expect(within(readyHeading.closest("article") as HTMLElement).getByText("MCP")) .toBeInTheDocument(); @@ -354,7 +348,7 @@ describe("SettingsView Apps catalog", () => { }); renderSettingsView({ initialSection: "apps" }); - fireEvent.click(await screen.findByRole("button", { name: "MCP" })); + fireEvent.click(await screen.findByRole("button", { name: "Add custom" })); fireEvent.click(await screen.findByRole("button", { name: "Custom" })); expect(screen.queryByText("Authentication")).not.toBeInTheDocument(); @@ -400,6 +394,7 @@ describe("SettingsView Apps catalog", () => { expect(values).not.toHaveProperty("headers"); expect(saveCall?.[2]).toBe(20_000); }); + fireEvent.click(screen.getByRole("button", { name: "All apps" })); expect(await screen.findByRole("button", { name: "Connect team-mcp" })) .toBeInTheDocument(); expect( @@ -482,7 +477,7 @@ describe("SettingsView Apps catalog", () => { vi.stubGlobal("open", vi.fn(() => popup)); renderSettingsView({ initialSection: "apps" }); - fireEvent.click(await screen.findByRole("button", { name: "MCP" })); + fireEvent.click(await screen.findByRole("button", { name: "All apps" })); fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" })); const callbackInput = await screen.findByRole("textbox", { name: "Full callback URL" }); @@ -551,7 +546,7 @@ describe("SettingsView Apps catalog", () => { vi.stubGlobal("open", vi.fn(() => popup)); renderSettingsView({ initialSection: "apps" }); - fireEvent.click(await screen.findByRole("button", { name: "MCP" })); + fireEvent.click(await screen.findByRole("button", { name: "All apps" })); fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" })); const cancelButton = await screen.findByRole("button", { name: "Cancel" }); @@ -609,7 +604,7 @@ describe("SettingsView Apps catalog", () => { }); renderSettingsView({ initialSection: "apps" }); - fireEvent.click(await screen.findByRole("button", { name: "MCP" })); + fireEvent.click(await screen.findByRole("button", { name: "All apps" })); fireEvent.click(await screen.findByRole("button", { name: "Remove" })); await waitFor(() => expect(requestMutationMock).toHaveBeenCalledWith( @@ -657,7 +652,7 @@ describe("SettingsView Apps catalog", () => { vi.stubGlobal("open", open); renderSettingsView({ initialSection: "apps" }); - fireEvent.click(await screen.findByRole("button", { name: "MCP" })); + fireEvent.click(await screen.findByRole("button", { name: "All apps" })); fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" })); const continueButton = await screen.findByRole("button", { name: "Continue sign-in" }); @@ -727,7 +722,7 @@ describe("SettingsView Apps catalog", () => { vi.stubGlobal("open", vi.fn(() => popup)); renderSettingsView({ initialSection: "apps" }); - fireEvent.click(await screen.findByRole("button", { name: "MCP" })); + fireEvent.click(await screen.findByRole("button", { name: "All apps" })); fireEvent.click(await screen.findByRole("button", { name: "Connect Xmind" })); await waitFor(() => expect(statusCalls).toBe(1), { timeout: 2000 }); diff --git a/webui/src/tests/settings-overview.test.tsx b/webui/src/tests/settings-overview.test.tsx index a9dfdc970..a77532eb1 100644 --- a/webui/src/tests/settings-overview.test.tsx +++ b/webui/src/tests/settings-overview.test.tsx @@ -3,8 +3,8 @@ import { expect, it, vi } from "vitest"; import type { SettingsPayload } from "@/lib/types"; import { jsonResponse, settingsPayload, renderSettingsView, installSettingsViewTestHooks } from "@/tests/settings-test-utils"; - const thirdPartyBrandNotice = - "Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement."; +const brandLogoHelp = + "Load third-party brand logos from external icon services. Turn this off to use local initials."; describe("Settings overview and appearance", () => { installSettingsViewTestHooks(); @@ -26,7 +26,7 @@ describe("Settings overview and appearance", () => { }); }); - it("shows the third-party brand notice only with the brand logo preference", () => { + it("explains the external request behind the brand logo preference", () => { renderSettingsView({ initialSection: "appearance", initialSettings: settingsPayload(), @@ -38,20 +38,10 @@ describe("Settings overview and appearance", () => { expect(brandLogosRow).not.toBeNull(); expect( - within(brandLogosRow as HTMLElement).getByText(thirdPartyBrandNotice), + within(brandLogosRow as HTMLElement).getByText(brandLogoHelp), ).toBeInTheDocument(); - expect(screen.getAllByText(thirdPartyBrandNotice)).toHaveLength(1); }); - it.each(["apps", "channels"] as const)( - "does not repeat the third-party brand notice in %s", - (initialSection) => { - renderSettingsView({ initialSection, initialSettings: settingsPayload() }); - - expect(screen.queryByText(thirdPartyBrandNotice)).not.toBeInTheDocument(); - }, - ); - it("publishes the latest settings payload to the shell", async () => { const payload = settingsPayload(); const onSettingsChange = vi.fn(); @@ -100,7 +90,7 @@ describe("Settings overview and appearance", () => { expect(await screen.findByText("No apps available.")).toBeInTheDocument(); expect(screen.queryByText("Loading Apps...")).not.toBeInTheDocument(); - fireEvent.click(screen.getByRole("button", { name: "Browse MCP tools" })); + fireEvent.click(screen.getByRole("button", { name: "Add custom" })); expect(await screen.findByText("Add MCP server")).toBeInTheDocument(); }); diff --git a/webui/src/tests/settings-system.test.tsx b/webui/src/tests/settings-system.test.tsx index dae5a3322..812016304 100644 --- a/webui/src/tests/settings-system.test.tsx +++ b/webui/src/tests/settings-system.test.tsx @@ -71,6 +71,7 @@ describe("Settings system domains", () => { renderSettingsView(); + fireEvent.click(await screen.findByRole("button", { name: "Installed" })); expect(await screen.findByText("Computer Use")).toBeInTheDocument(); expect(screen.getByText("Plugins")).toBeInTheDocument(); expect(screen.getByText(/Control the desktop.*screen-recording, accessibility/)).toBeInTheDocument(); @@ -263,6 +264,7 @@ describe("Settings system domains", () => { renderSettingsView(); expect(screen.queryByRole("heading", { name: "Apps" })).not.toBeInTheDocument(); + fireEvent.click(await screen.findByRole("button", { name: "Installed" })); expect(await screen.findByText("AnyGen")).toBeInTheDocument(); const uninstall = screen.getByRole("button", { name: "Uninstall app" }); @@ -282,7 +284,7 @@ describe("Settings system domains", () => { expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument(); }); - it("keeps runtime dependencies out of Apps and explains chat mentions", async () => { + it("opens Apps on a focused Discover surface", async () => { vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { const url = String(input); if (url === "/api/settings") return jsonResponse(settingsPayload()); @@ -295,6 +297,13 @@ describe("Settings system domains", () => { if (url === "/api/settings/mcp-presets") { return jsonResponse({ presets: [], installed_count: 0 }); } + if (url === "/api/settings/apps-discovery") { + return jsonResponse({ + schema_version: 1, + updated_at: "2026-08-12T00:00:00Z", + featured: ["cli:anygen"], + }); + } if (url === "/api/settings/nanobot-features") { return jsonResponse({ features: [ @@ -319,17 +328,111 @@ describe("Settings system domains", () => { renderSettingsView({ initialSection: "apps" }); expect(await screen.findByText("AnyGen")).toBeInTheDocument(); - expect( - screen.queryByText("Add tools to nanobot, then @ them in chat."), - ).not.toBeInTheDocument(); - expect(screen.getByRole("button", { name: "Ready" })).toHaveAttribute("aria-pressed", "false"); - expect(screen.getByRole("button", { name: "Apps" })).toHaveAttribute("aria-pressed", "true"); - expect(screen.getByRole("button", { name: "MCP" })).toBeInTheDocument(); + expect(screen.getByText("Add tools to nanobot, then @ them in chat.")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Discover" })).toHaveAttribute("aria-pressed", "true"); + expect(screen.getByRole("button", { name: "Installed" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "All apps" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Add custom" })).toBeInTheDocument(); + expect(screen.getByText("Featured")).toBeInTheDocument(); expect(screen.queryByRole("button", { name: "Plugins" })).not.toBeInTheDocument(); expect(screen.queryByText("Api")).not.toBeInTheDocument(); expect(screen.queryByText("0 ready")).not.toBeInTheDocument(); }); + it("rotates only through the curated Featured candidates", async () => { + const apps = Array.from({ length: 7 }, (_, index) => ({ + ...installedAnyGen, + name: `app-${index + 1}`, + display_name: `App ${index + 1}`, + installed: false, + status: "available", + })); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/settings") return jsonResponse(settingsPayload()); + if (url === "/api/settings/cli-apps") { + return jsonResponse({ apps, installed_count: 0 }); + } + if (url === "/api/settings/mcp-presets") { + return jsonResponse({ presets: [], installed_count: 0 }); + } + if (url === "/api/settings/apps-discovery") { + return jsonResponse({ + schema_version: 1, + updated_at: "2026-08-12T00:00:00Z", + featured: apps.map((app) => `cli:${app.name}`), + }); + } + return jsonResponse({}); + })); + + renderSettingsView({ initialSection: "apps" }); + + expect(await screen.findByText("App 1")).toBeInTheDocument(); + expect(screen.queryByText("App 7")).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Show another" })); + expect(await screen.findByText("App 7")).toBeInTheDocument(); + expect(screen.queryByText("App 1")).not.toBeInTheDocument(); + }); + + it("does not promote uncurated apps when Featured has fewer than six entries", async () => { + const apps = [1, 2].map((index) => ({ + ...installedAnyGen, + name: `app-${index}`, + display_name: `App ${index}`, + installed: false, + status: "available", + })); + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/settings") return jsonResponse(settingsPayload()); + if (url === "/api/settings/cli-apps") { + return jsonResponse({ apps, installed_count: 0 }); + } + if (url === "/api/settings/mcp-presets") { + return jsonResponse({ presets: [], installed_count: 0 }); + } + if (url === "/api/settings/apps-discovery") { + return jsonResponse({ + schema_version: 1, + updated_at: "2026-08-12T00:00:00Z", + featured: ["cli:app-1"], + }); + } + return jsonResponse({}); + })); + + renderSettingsView({ initialSection: "apps" }); + + expect(await screen.findByText("App 1")).toBeInTheDocument(); + expect(screen.queryByText("App 2")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Show another" })).not.toBeInTheDocument(); + }); + + it("clears search before opening custom MCP setup", async () => { + vi.stubGlobal("fetch", vi.fn(async (input: RequestInfo | URL) => { + const url = String(input); + if (url === "/api/settings") return jsonResponse(settingsPayload()); + if (url === "/api/settings/cli-apps") { + return jsonResponse({ apps: [installedAnyGen], installed_count: 1 }); + } + if (url === "/api/settings/mcp-presets") { + return jsonResponse({ presets: [], installed_count: 0 }); + } + return jsonResponse({}); + })); + + renderSettingsView({ initialSection: "apps" }); + + const search = await screen.findByPlaceholderText("Search tools"); + fireEvent.change(search, { target: { value: "AnyGen" } }); + expect(await screen.findByText("Search results")).toBeInTheDocument(); + fireEvent.click(screen.getByRole("button", { name: "Add custom" })); + + expect(search).toHaveValue(""); + expect(await screen.findByText("Add MCP server")).toBeInTheDocument(); + }); + it("shows nanobot optional features and enables one", async () => { const fetchMock = vi.fn(async (input: RequestInfo | URL) => { const url = String(input);