Compare commits

...
Author SHA1 Message Date
Xubin Ren f94e1d73e2 feat(webui): redesign apps discovery 2026-08-12 09:34:13 +09:00
31 changed files with 869 additions and 217 deletions
+33 -4
View File
@@ -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:
+129
View File
@@ -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}
+3
View File
@@ -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,
+12
View File
@@ -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,
+65
View File
@@ -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
+70
View File
@@ -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(
+19
View File
@@ -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(
@@ -62,6 +62,7 @@ export function SettingsPage({
apiServiceAction,
apiServiceError,
apiServiceLoading,
appsDiscovery,
appsKindFilter,
appsQuery,
automationAction,
@@ -387,6 +388,7 @@ export function SettingsPage({
case "apps":
return (
<AppsCatalogSettings
discovery={appsDiscovery}
cliApps={cliApps}
mcpPresets={mcpPresets}
cliAppsLoading={cliAppsLoading}
@@ -410,8 +410,8 @@ export function AppearanceSettings({
<SettingsRow
title={tx("settings.rows.brandLogos", "Brand logos")}
description={tx(
"settings.legal.thirdPartyBrands",
"Product names, logos, and brands are property of their respective owners. Use is for identification only and does not imply endorsement.",
"settings.help.brandLogos",
"Load third-party brand logos from external icon services. Turn this off to use local initials.",
)}
>
<ToggleButton
@@ -49,6 +49,7 @@ import { Textarea } from "@/components/ui/textarea";
import { useLogoFallback } from "@/hooks/useLogoFallback";
import { isGenericRepositoryLogoUrl, logoFallbackUrls } from "@/lib/provider-brand";
import type {
AppsDiscoveryPayload,
CliAppInfo,
CliAppsPayload,
McpOAuthFlowPayload,
@@ -57,7 +58,7 @@ import type {
} from "@/lib/types";
import { cn } from "@/lib/utils";
export type AppsKindFilter = "ready" | "cli" | "mcp";
export type AppsKindFilter = "discover" | "installed" | "all" | "custom";
type AppsCatalogItem =
| { id: string; kind: "cli"; app: CliAppInfo }
| { id: string; kind: "mcp"; preset: McpPresetInfo };
@@ -65,6 +66,21 @@ type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
type CustomMcpAuth = "none" | "oauth" | "headers";
export const CLI_APPS_REFRESH_RETRY_MS = 2_000;
export const CLI_APPS_REFRESH_MAX_RETRIES = 30;
const FEATURED_BATCH_SIZE = 6;
const DEFAULT_FEATURED_IDS = [
"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",
];
export interface CustomMcpForm {
name: string;
@@ -91,6 +107,7 @@ export const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = {
};
export function AppsCatalogSettings({
discovery,
cliApps,
mcpPresets,
cliAppsLoading,
@@ -134,6 +151,7 @@ export function AppsCatalogSettings({
onRestart,
isRestarting,
}: {
discovery: AppsDiscoveryPayload | null;
cliApps: CliAppsPayload | null;
mcpPresets: McpPresetsPayload | null;
cliAppsLoading: boolean;
@@ -178,33 +196,41 @@ export function AppsCatalogSettings({
isRestarting?: boolean;
}) {
const { t } = useTranslation();
const [featuredBatch, setFeaturedBatch] = useState(0);
const tx = (key: string, fallback: string) => 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" ? (
<CliAppsCatalogRow
key={item.id}
app={item.app}
actionKey={cliActionKey}
showBrandLogos={showBrandLogos}
onAction={onCliAction}
/>
) : (
<McpAppsCatalogRow
key={item.id}
preset={item.preset}
values={mcpFieldValues[item.preset.name] ?? {}}
actionKey={mcpActionKey}
oauthFlow={mcpOAuthFlow?.name === item.preset.name ? mcpOAuthFlow : null}
oauthPopupBlocked={mcpOAuthPopupBlocked}
oauthCallbackUrl={mcpOAuthCallbackUrl}
oauthCompleting={mcpOAuthCompleting}
oauthCallbackError={mcpOAuthCallbackError}
showBrandLogos={showBrandLogos}
showTypeBadge
onFieldChange={onMcpFieldChange}
onAction={onMcpAction}
onOAuthConnect={onMcpOAuthConnect}
onOAuthCancel={onMcpOAuthCancel}
onOAuthOpen={onMcpOAuthOpen}
onOAuthCallbackUrlChange={onMcpOAuthCallbackUrlChange}
onOAuthComplete={onMcpOAuthComplete}
onToolsChange={onMcpToolsChange}
/>
);
const catalogGrid = (items: AppsCatalogItem[]) => (
<div className="grid grid-cols-1 border-t border-border/45 xl:grid-cols-2">
{items.map((item) => (
<div key={item.id} className="min-w-0 border-b border-border/45 xl:odd:border-r">
{renderItem(item)}
</div>
))}
</div>
);
return (
<div className="space-y-7">
<div role="status" className="sr-only">{oauthStatusAnnouncement}</div>
<section className="space-y-4">
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
<section className="space-y-5">
<p className="text-[14px] leading-6 text-muted-foreground">
{tx("settings.apps.description", "Add tools to nanobot, then @ them in chat.")}
</p>
<div className="flex flex-col gap-3 xl:flex-row xl:items-center">
<div className="relative flex-1">
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
<Input
@@ -262,8 +323,17 @@ export function AppsCatalogSettings({
<SegmentedControl
value={filter}
options={filterOptions}
onChange={(value) => onFilterChange(value as AppsKindFilter)}
onChange={(value) => selectFilter(value as AppsKindFilter)}
/>
<Button
type="button"
variant={filter === "custom" ? "secondary" : "outline"}
className="h-10 shrink-0 rounded-full px-4"
onClick={() => selectFilter("custom")}
>
<Plus className="mr-1.5 h-4 w-4" aria-hidden />
{tx("settings.apps.addCustom", "Add custom")}
</Button>
</div>
</section>
@@ -287,62 +357,22 @@ export function AppsCatalogSettings({
/>
) : null}
<section className="rounded-[22px] bg-settings-surface px-3 py-3 sm:px-4">
<div className="flex items-center justify-between border-b border-border/45 pb-3">
<SettingsSectionTitle>
{filter === "mcp"
? tx("settings.apps.mcpTools", "MCP tools")
: tx("settings.apps.featured", "Tools")}
</SettingsSectionTitle>
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
{items.length}
</span>
</div>
{loading ? (
{loading ? (
<section className="rounded-[22px] bg-settings-surface">
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
{tx("settings.apps.loading", "Loading Apps...")}
</div>
) : items.length ? (
<div className="grid grid-cols-1 gap-x-10 gap-y-1 py-3 xl:grid-cols-2">
{items.map((item) =>
item.kind === "cli" ? (
<CliAppsCatalogRow
key={item.id}
app={item.app}
actionKey={cliActionKey}
showBrandLogos={showBrandLogos}
onAction={onCliAction}
/>
) : (
<McpAppsCatalogRow
key={item.id}
preset={item.preset}
values={mcpFieldValues[item.preset.name] ?? {}}
actionKey={mcpActionKey}
oauthFlow={mcpOAuthFlow?.name === item.preset.name ? mcpOAuthFlow : null}
oauthPopupBlocked={mcpOAuthPopupBlocked}
oauthCallbackUrl={mcpOAuthCallbackUrl}
oauthCompleting={mcpOAuthCompleting}
oauthCallbackError={mcpOAuthCallbackError}
showBrandLogos={showBrandLogos}
showTypeBadge={filter !== "mcp"}
onFieldChange={onMcpFieldChange}
onAction={onMcpAction}
onOAuthConnect={onMcpOAuthConnect}
onOAuthCancel={onMcpOAuthCancel}
onOAuthOpen={onMcpOAuthOpen}
onOAuthCallbackUrlChange={onMcpOAuthCallbackUrlChange}
onOAuthComplete={onMcpOAuthComplete}
onToolsChange={onMcpToolsChange}
/>
),
)}
</section>
) : normalizedQuery ? (
<section>
<div className="mb-3 flex items-baseline gap-2 px-1">
<SettingsSectionTitle>{tx("settings.apps.searchResults", "Search results")}</SettingsSectionTitle>
<span className="text-[12px] text-muted-foreground">{visibleItems.length}</span>
</div>
) : (
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
<p>{emptyTitle}</p>
{normalizedQuery ? (
{visibleItems.length ? catalogGrid(visibleItems) : (
<div className="py-12 text-center text-sm text-muted-foreground">
<p>{emptyTitle}</p>
<Button
type="button"
variant="outline"
@@ -351,30 +381,46 @@ export function AppsCatalogSettings({
>
{tx("settings.apps.clearSearch", "Clear search")}
</Button>
) : emptyBrowseTarget ? (
</div>
)}
</section>
) : filter === "discover" ? (
<section>
<div className="mb-3 flex items-center justify-between gap-3 px-1">
<div className="flex items-baseline gap-2">
<SettingsSectionTitle>{tx("settings.apps.featured", "Featured")}</SettingsSectionTitle>
<span className="text-[12px] text-muted-foreground">{featuredItems.length}</span>
</div>
<div className="flex items-center gap-1">
{featuredBatchCount > 1 ? (
<Button
type="button"
variant="ghost"
className="h-8 rounded-full px-3 text-[12px] text-muted-foreground"
onClick={() => setFeaturedBatch((batch) => (batch + 1) % featuredBatchCount)}
>
<RotateCcw className="mr-1.5 h-3.5 w-3.5" aria-hidden />
{tx("settings.apps.nextFeatured", "Show another")}
</Button>
) : null}
<Button
type="button"
variant="outline"
className="mt-4 rounded-full"
onClick={() => onFilterChange(emptyBrowseTarget)}
variant="ghost"
className="h-8 rounded-full px-3 text-[12px] text-muted-foreground"
onClick={() => selectFilter("all")}
>
{emptyBrowseTarget === "cli"
? tx("settings.apps.browseApps", "Browse apps")
: tx("settings.apps.browseIntegrations", "Browse MCP tools")}
{tx("settings.apps.browseAll", "Browse all")}
<ChevronRight className="ml-1 h-3.5 w-3.5" aria-hidden />
</Button>
) : (
<p className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
{tx(
"settings.apps.emptyIntegrationsHint",
"Add a custom MCP server below.",
)}
</p>
)}
</div>
</div>
)}
</section>
{filter === "mcp" ? (
{featuredItems.length ? catalogGrid(featuredItems) : (
<div className="py-12 text-center text-sm text-muted-foreground">
{tx("settings.apps.emptyApps", "No apps available.")}
</div>
)}
</section>
) : filter === "custom" ? (
<McpCustomServerPanel
form={customMcpForm}
configImport={mcpConfigImport}
@@ -384,7 +430,33 @@ export function AppsCatalogSettings({
onSave={onSaveCustomMcp}
onImportConfig={onImportMcpConfig}
/>
) : null}
) : (
<section>
<div className="mb-3 flex items-baseline gap-2 px-1">
<SettingsSectionTitle>
{filter === "installed"
? tx("settings.apps.installed", "Installed")
: tx("settings.apps.allApps", "All apps")}
</SettingsSectionTitle>
<span className="text-[12px] text-muted-foreground">{visibleItems.length}</span>
</div>
{visibleItems.length ? catalogGrid(visibleItems) : (
<div className="py-12 text-center text-sm text-muted-foreground">
<p>{emptyTitle}</p>
{filter === "installed" ? (
<Button
type="button"
variant="outline"
className="mt-4 rounded-full"
onClick={() => selectFilter("discover")}
>
{tx("settings.apps.discover", "Discover")}
</Button>
) : null}
</div>
)}
</section>
)}
</div>
);
}
@@ -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 {
@@ -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;
@@ -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<AppsDiscoveryPayload | null>(null);
const [cliApps, setCliApps] = useState<CliAppsPayload | null>(null);
const [nanobotFeatures, setNanobotFeatures] = useState<NanobotFeaturesPayload | null>(null);
const [mcpPresets, setMcpPresets] = useState<McpPresetsPayload | null>(null);
@@ -54,7 +56,7 @@ export function useSystemSettingsState() {
const [cliAppsError, setCliAppsError] = useState<string | null>(null);
const [nanobotFeaturesError, setNanobotFeaturesError] = useState<string | null>(null);
const [cliAppsFocusName, setCliAppsFocusName] = useState<string | null>(null);
const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("cli");
const [appsKindFilter, setAppsKindFilter] = useState<AppsKindFilter>("discover");
const [mcpMessage, setMcpMessage] = useState<string | null>(null);
const [mcpError, setMcpError] = useState<string | null>(null);
const [automationsError, setAutomationsError] = useState<string | null>(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,
@@ -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,
+10 -5
View File
@@ -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.",
+10 -5
View File
@@ -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.",
+10 -5
View File
@@ -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 dicônes externes. Désactivez cette option pour utiliser des initiales locales.",
"cliAppsCatalog": "Installe uniquement les adaptateurs CLI dapplications 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 dautres",
"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.",
+10 -5
View File
@@ -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.",
+10 -5
View File
@@ -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": "検索条件に一致するツールはありません。",
+10 -5
View File
@@ -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": "검색과 일치하는 도구가 없습니다.",
+10 -5
View File
@@ -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.",
+10 -5
View File
@@ -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.",
+10 -5
View File
@@ -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": "没有与搜索条件匹配的工具。",
+10 -5
View File
@@ -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": "沒有符合搜尋條件的工具。",
+13
View File
@@ -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<AppsDiscoveryPayload> {
return request<AppsDiscoveryPayload>(
`${base}/api/settings/apps-discovery`,
token,
undefined,
API_READ_TIMEOUT_MS,
);
}
export async function fetchInstalledCliApps(
token: string,
base: string = "",
+10 -4
View File
@@ -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<LocalPreferences> & { 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<LocalPreferences>;
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.
}
+7
View File
@@ -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;
+1 -1
View File
@@ -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(
+48
View File
@@ -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,
});
});
});
+15 -20
View File
@@ -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 });
+5 -15
View File
@@ -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();
});
+110 -7
View File
@@ -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);