feat(apps): unify CLI apps and MCP (#3991)
* refactor(cli): load bundled apps from catalog * feat(plugins): unify CLI and MCP settings * feat(plugins): add settings category filter * style(plugins): refine settings catalog * refactor(cli): load nanobot apps from repo catalog * feat(store): add capability store entry * feat(apps): rename capability store * fix(apps): verify clean app removal * fix(apps): keep main sidebar on apps view * feat(apps): add shared app manifest protocol * fix(apps): dismiss app status message * refactor(apps): move CLI adapter under apps * refactor(apps): drop legacy cli apps package
This commit is contained in:
+24
-5
@@ -4,7 +4,7 @@ import { DeleteConfirm } from "@/components/DeleteConfirm";
|
||||
import { RenameChatDialog } from "@/components/RenameChatDialog";
|
||||
import { Sidebar } from "@/components/Sidebar";
|
||||
import { SessionSearchDialog } from "@/components/SessionSearchDialog";
|
||||
import { SettingsView } from "@/components/settings/SettingsView";
|
||||
import { SettingsView, type SettingsSectionKey } from "@/components/settings/SettingsView";
|
||||
import { ThreadShell } from "@/components/thread/ThreadShell";
|
||||
import { Sheet, SheetContent, SheetTitle } from "@/components/ui/sheet";
|
||||
|
||||
@@ -46,7 +46,7 @@ const SIDEBAR_WIDTH = 272;
|
||||
const SIDEBAR_RAIL_WIDTH = 56;
|
||||
const TOKEN_REFRESH_MARGIN_MS = 30_000;
|
||||
const TOKEN_REFRESH_MIN_DELAY_MS = 5_000;
|
||||
type ShellView = "chat" | "settings";
|
||||
type ShellView = "chat" | "settings" | "apps";
|
||||
|
||||
function bootstrapTokenExpiresAt(expiresInSeconds: number): number {
|
||||
return Date.now() + Math.max(0, expiresInSeconds) * 1000;
|
||||
@@ -325,6 +325,7 @@ function Shell({
|
||||
useSidebarState(sessions, !loading);
|
||||
const [activeKey, setActiveKey] = useState<string | null>(null);
|
||||
const [view, setView] = useState<ShellView>("chat");
|
||||
const [settingsInitialSection, setSettingsInitialSection] = useState<SettingsSectionKey>("overview");
|
||||
const [desktopSidebarOpen, setDesktopSidebarOpen] =
|
||||
useState<boolean>(readSidebarOpen);
|
||||
const [mobileSidebarOpen, setMobileSidebarOpen] = useState(false);
|
||||
@@ -588,12 +589,20 @@ function Shell({
|
||||
[onSelectChat],
|
||||
);
|
||||
|
||||
const onOpenSettings = useCallback(() => {
|
||||
const onOpenSettings = useCallback((section: SettingsSectionKey = "overview") => {
|
||||
setSessionSearchOpen(false);
|
||||
setSettingsInitialSection(section);
|
||||
setView("settings");
|
||||
setMobileSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const onOpenApps = useCallback(() => {
|
||||
setSessionSearchOpen(false);
|
||||
setSettingsInitialSection("apps");
|
||||
setView("apps");
|
||||
setMobileSidebarOpen(false);
|
||||
}, []);
|
||||
|
||||
const onBackToChat = useCallback(() => {
|
||||
setView("chat");
|
||||
setMobileSidebarOpen(false);
|
||||
@@ -711,6 +720,12 @@ function Shell({
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (view === "apps") {
|
||||
document.title = t("app.documentTitle.chat", {
|
||||
title: t("settings.nav.apps", { defaultValue: "Apps" }),
|
||||
});
|
||||
return;
|
||||
}
|
||||
document.title = activeSession
|
||||
? t("app.documentTitle.chat", { title: headerTitle })
|
||||
: t("app.documentTitle.base");
|
||||
@@ -728,7 +743,9 @@ function Shell({
|
||||
onRequestRename,
|
||||
onToggleArchive,
|
||||
onOpenSettings,
|
||||
onOpenApps,
|
||||
onOpenSearch: onOpenSessionSearch,
|
||||
activeUtility: view === "apps" ? "apps" as const : null,
|
||||
onToggleArchived,
|
||||
onUpdateView: onUpdateSidebarView,
|
||||
pinnedKeys: sidebarState.pinned_keys,
|
||||
@@ -805,7 +822,7 @@ function Shell({
|
||||
<div
|
||||
className={cn(
|
||||
"absolute inset-0 flex flex-col",
|
||||
view === "settings" && "invisible pointer-events-none",
|
||||
view !== "chat" && "invisible pointer-events-none",
|
||||
)}
|
||||
>
|
||||
<ThreadShell
|
||||
@@ -820,10 +837,12 @@ function Shell({
|
||||
hideSidebarToggleOnDesktop
|
||||
/>
|
||||
</div>
|
||||
{view === "settings" && (
|
||||
{view !== "chat" && (
|
||||
<div className="absolute inset-0 flex flex-col">
|
||||
<SettingsView
|
||||
theme={theme}
|
||||
initialSection={settingsInitialSection}
|
||||
showSidebar={view === "settings"}
|
||||
onToggleTheme={toggle}
|
||||
onBackToChat={onBackToChat}
|
||||
onModelNameChange={onModelNameChange}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
Search,
|
||||
Settings,
|
||||
SquarePen,
|
||||
Blocks,
|
||||
} from "lucide-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
@@ -41,7 +42,9 @@ interface SidebarProps {
|
||||
onRequestRename: (key: string, label: string) => void;
|
||||
onToggleArchive: (key: string) => void;
|
||||
onOpenSettings: () => void;
|
||||
onOpenApps: () => void;
|
||||
onOpenSearch: () => void;
|
||||
activeUtility?: "apps" | null;
|
||||
onToggleArchived: () => void;
|
||||
onUpdateView: (view: Partial<SidebarViewState>) => void;
|
||||
onCollapse: () => void;
|
||||
@@ -129,6 +132,13 @@ export function Sidebar(props: SidebarProps) {
|
||||
onClick={props.onOpenSearch}
|
||||
icon={<Search className="h-4 w-4" />}
|
||||
/>
|
||||
<SidebarActionButton
|
||||
collapsed={collapsed}
|
||||
label={t("sidebar.apps")}
|
||||
onClick={props.onOpenApps}
|
||||
active={props.activeUtility === "apps"}
|
||||
icon={<Blocks className="h-4 w-4" />}
|
||||
/>
|
||||
<SidebarViewMenu
|
||||
compact={collapsed}
|
||||
view={props.viewState}
|
||||
@@ -201,12 +211,14 @@ function SidebarActionButton({
|
||||
label,
|
||||
icon,
|
||||
onClick,
|
||||
active = false,
|
||||
className,
|
||||
}: {
|
||||
collapsed: boolean;
|
||||
label: string;
|
||||
icon: ReactNode;
|
||||
onClick: () => void;
|
||||
active?: boolean;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
@@ -214,14 +226,16 @@ function SidebarActionButton({
|
||||
type="button"
|
||||
variant="ghost"
|
||||
aria-label={label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
title={collapsed ? label : undefined}
|
||||
onClick={onClick}
|
||||
onClick={() => onClick()}
|
||||
className={cn(
|
||||
"group h-8 min-w-0 gap-2 overflow-hidden rounded-full font-medium text-sidebar-foreground/85 hover:bg-sidebar-accent/75 hover:text-sidebar-foreground",
|
||||
"transition-[width,padding,border-radius,color,background-color] duration-300 ease-out",
|
||||
collapsed
|
||||
? "w-9 justify-center gap-0 rounded-xl px-0"
|
||||
: "w-full justify-start gap-2 px-3 text-[12.5px]",
|
||||
active && "bg-sidebar-accent text-sidebar-foreground shadow-[inset_0_0_0_1px_hsl(var(--sidebar-border)/0.55)]",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "Language",
|
||||
"ariaLabel": "Change language"
|
||||
}
|
||||
},
|
||||
"apps": "Apps"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Back to chat",
|
||||
@@ -83,7 +84,8 @@
|
||||
"cliApps": "CLI Apps",
|
||||
"mcp": "MCP",
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced"
|
||||
"advanced": "Advanced",
|
||||
"apps": "Apps"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "Interface",
|
||||
@@ -96,12 +98,13 @@
|
||||
"imageDefaults": "Defaults",
|
||||
"webSearch": "Web search",
|
||||
"webBehavior": "Behavior",
|
||||
"cliApps": "CLI Apps",
|
||||
"mcp": "MCP",
|
||||
"cliApps": "CLI apps",
|
||||
"mcp": "MCP services",
|
||||
"identity": "Identity",
|
||||
"safety": "Safety",
|
||||
"capabilities": "Capabilities",
|
||||
"integrations": "Integrations"
|
||||
"integrations": "Integrations",
|
||||
"apps": "Apps"
|
||||
},
|
||||
"models": {
|
||||
"selectModel": "Select model",
|
||||
@@ -380,6 +383,20 @@
|
||||
"selectSize": "Select size",
|
||||
"configureProvider": "Configure provider",
|
||||
"missingCredential": "Configure this provider before enabling image generation."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Add app CLIs and MCP services nanobot can use from chat.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"filterAll": "All",
|
||||
"filterCli": "CLI apps",
|
||||
"filterMcp": "MCP services",
|
||||
"enabledSummary": "{{count}} enabled",
|
||||
"caption": "{{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "Search Apps",
|
||||
"featured": "Featured",
|
||||
"loading": "Loading Apps...",
|
||||
"empty": "No apps match this filter."
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@@ -596,10 +613,10 @@
|
||||
}
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "Apps and MCP",
|
||||
"label": "Plugins",
|
||||
"cliGroup": "CLI Apps",
|
||||
"mcpGroup": "MCP servers",
|
||||
"ariaLabel": "Apps",
|
||||
"label": "Apps",
|
||||
"cliGroup": "CLI apps",
|
||||
"mcpGroup": "MCP services",
|
||||
"cliBadge": "CLI",
|
||||
"mcpBadge": "MCP",
|
||||
"cliDescription": "Use @{{name}} as a local CLI app",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "Idioma",
|
||||
"ariaLabel": "Cambiar idioma"
|
||||
}
|
||||
},
|
||||
"apps": "Apps"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Volver al chat",
|
||||
@@ -83,7 +84,8 @@
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "Apps CLI",
|
||||
"mcp": "MCP"
|
||||
"mcp": "MCP",
|
||||
"apps": "Apps"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "Interfaz",
|
||||
@@ -101,7 +103,8 @@
|
||||
"capabilities": "Capacidades",
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "Apps CLI",
|
||||
"mcp": "MCP"
|
||||
"mcp": "Servicios MCP",
|
||||
"apps": "Apps"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "Tema",
|
||||
@@ -380,6 +383,20 @@
|
||||
},
|
||||
"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 CLI de apps y servicios MCP que nanobot puede usar desde el chat.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"filterAll": "Todo",
|
||||
"filterCli": "Apps CLI",
|
||||
"filterMcp": "Servicios MCP",
|
||||
"enabledSummary": "{{count}} activados",
|
||||
"caption": "{{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "Buscar apps",
|
||||
"featured": "Destacadas",
|
||||
"loading": "Cargando apps...",
|
||||
"empty": "Ninguna app coincide con este filtro."
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@@ -607,10 +624,10 @@
|
||||
"io": "No se pudo leer este archivo"
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "Apps y MCP",
|
||||
"label": "Plugins",
|
||||
"ariaLabel": "Apps",
|
||||
"label": "Apps",
|
||||
"cliGroup": "Apps CLI",
|
||||
"mcpGroup": "Servidores MCP",
|
||||
"mcpGroup": "Servicios MCP",
|
||||
"cliBadge": "CLI",
|
||||
"mcpBadge": "MCP",
|
||||
"cliDescription": "Usar @{{name}} como app CLI local",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "Langue",
|
||||
"ariaLabel": "Changer de langue"
|
||||
}
|
||||
},
|
||||
"apps": "Apps"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Retour à la discussion",
|
||||
@@ -83,7 +84,8 @@
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "Apps CLI",
|
||||
"mcp": "MCP"
|
||||
"mcp": "MCP",
|
||||
"apps": "Apps"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "Interface",
|
||||
@@ -101,7 +103,8 @@
|
||||
"capabilities": "Capacités",
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "Apps CLI",
|
||||
"mcp": "MCP"
|
||||
"mcp": "Services MCP",
|
||||
"apps": "Apps"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "Thème",
|
||||
@@ -380,6 +383,20 @@
|
||||
},
|
||||
"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 CLI d’apps et des services MCP que nanobot peut utiliser dans le chat.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"filterAll": "Tout",
|
||||
"filterCli": "Apps CLI",
|
||||
"filterMcp": "Services MCP",
|
||||
"enabledSummary": "{{count}} activés",
|
||||
"caption": "{{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "Rechercher des apps",
|
||||
"featured": "En vedette",
|
||||
"loading": "Chargement des apps...",
|
||||
"empty": "Aucune app ne correspond à ce filtre."
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@@ -607,10 +624,10 @@
|
||||
"io": "Impossible de lire ce fichier"
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "Apps et MCP",
|
||||
"label": "Plugins",
|
||||
"ariaLabel": "Apps",
|
||||
"label": "Apps",
|
||||
"cliGroup": "Apps CLI",
|
||||
"mcpGroup": "Serveurs MCP",
|
||||
"mcpGroup": "Services MCP",
|
||||
"cliBadge": "CLI",
|
||||
"mcpBadge": "MCP",
|
||||
"cliDescription": "Utiliser @{{name}} comme app CLI locale",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "Bahasa",
|
||||
"ariaLabel": "Ganti bahasa"
|
||||
}
|
||||
},
|
||||
"apps": "Aplikasi"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Kembali ke obrolan",
|
||||
@@ -83,7 +84,8 @@
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "Aplikasi CLI",
|
||||
"mcp": "MCP"
|
||||
"mcp": "MCP",
|
||||
"apps": "Aplikasi"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "Antarmuka",
|
||||
@@ -100,8 +102,9 @@
|
||||
"safety": "Safety",
|
||||
"capabilities": "Kapabilitas",
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "Aplikasi CLI",
|
||||
"mcp": "MCP"
|
||||
"cliApps": "App CLI",
|
||||
"mcp": "Layanan MCP",
|
||||
"apps": "Aplikasi"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "Tema",
|
||||
@@ -380,6 +383,20 @@
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "Nama produk, logo, dan merek adalah milik pemiliknya masing-masing. Penggunaan hanya untuk identifikasi dan tidak menyiratkan dukungan."
|
||||
},
|
||||
"apps": {
|
||||
"description": "Tambahkan CLI app dan layanan MCP yang dapat digunakan nanobot dari chat.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"filterAll": "Semua",
|
||||
"filterCli": "App CLI",
|
||||
"filterMcp": "Layanan MCP",
|
||||
"enabledSummary": "{{count}} aktif",
|
||||
"caption": "{{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "Cari aplikasi",
|
||||
"featured": "Unggulan",
|
||||
"loading": "Memuat aplikasi...",
|
||||
"empty": "Tidak ada aplikasi yang cocok dengan filter ini."
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@@ -607,10 +624,10 @@
|
||||
"io": "Tidak dapat membaca file ini"
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "Aplikasi dan MCP",
|
||||
"label": "Plugin",
|
||||
"cliGroup": "Aplikasi CLI",
|
||||
"mcpGroup": "Server MCP",
|
||||
"ariaLabel": "Aplikasi",
|
||||
"label": "Aplikasi",
|
||||
"cliGroup": "App CLI",
|
||||
"mcpGroup": "Layanan MCP",
|
||||
"cliBadge": "CLI",
|
||||
"mcpBadge": "MCP",
|
||||
"cliDescription": "Gunakan @{{name}} sebagai aplikasi CLI lokal",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "言語",
|
||||
"ariaLabel": "言語を変更"
|
||||
}
|
||||
},
|
||||
"apps": "アプリ"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "チャットに戻る",
|
||||
@@ -83,7 +84,8 @@
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "CLI アプリ",
|
||||
"mcp": "MCP"
|
||||
"mcp": "MCP",
|
||||
"apps": "アプリ"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "インターフェース",
|
||||
@@ -101,7 +103,8 @@
|
||||
"capabilities": "機能",
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "CLI アプリ",
|
||||
"mcp": "MCP"
|
||||
"mcp": "MCP サービス",
|
||||
"apps": "アプリ"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "テーマ",
|
||||
@@ -380,6 +383,20 @@
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "製品名、ロゴ、ブランドはそれぞれの所有者に帰属します。使用は識別のみを目的とし、承認を意味するものではありません。"
|
||||
},
|
||||
"apps": {
|
||||
"description": "チャットから nanobot が使えるアプリ CLI と MCP サービスを追加します。",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"filterAll": "すべて",
|
||||
"filterCli": "CLI アプリ",
|
||||
"filterMcp": "MCP サービス",
|
||||
"enabledSummary": "{{count}} 件有効",
|
||||
"caption": "{{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "アプリを検索",
|
||||
"featured": "おすすめ",
|
||||
"loading": "アプリを読み込み中...",
|
||||
"empty": "このフィルターに一致するアプリはありません。"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@@ -607,10 +624,10 @@
|
||||
"io": "このファイルを読み込めません"
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "アプリと MCP",
|
||||
"label": "プラグイン",
|
||||
"ariaLabel": "アプリ",
|
||||
"label": "アプリ",
|
||||
"cliGroup": "CLI アプリ",
|
||||
"mcpGroup": "MCP サーバー",
|
||||
"mcpGroup": "MCP サービス",
|
||||
"cliBadge": "CLI",
|
||||
"mcpBadge": "MCP",
|
||||
"cliDescription": "@{{name}} をローカル CLI アプリとして使用",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "언어",
|
||||
"ariaLabel": "언어 변경"
|
||||
}
|
||||
},
|
||||
"apps": "앱"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "채팅으로 돌아가기",
|
||||
@@ -83,7 +84,8 @@
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "CLI 앱",
|
||||
"mcp": "MCP"
|
||||
"mcp": "MCP",
|
||||
"apps": "앱"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "인터페이스",
|
||||
@@ -101,7 +103,8 @@
|
||||
"capabilities": "기능",
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "CLI 앱",
|
||||
"mcp": "MCP"
|
||||
"mcp": "MCP 서비스",
|
||||
"apps": "앱"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "테마",
|
||||
@@ -380,6 +383,20 @@
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "제품 이름, 로고 및 브랜드는 각 소유자의 자산입니다. 사용은 식별 목적일 뿐 보증이나 제휴를 의미하지 않습니다."
|
||||
},
|
||||
"apps": {
|
||||
"description": "채팅에서 nanobot이 사용할 수 있는 앱 CLI와 MCP 서비스를 추가합니다.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"filterAll": "전체",
|
||||
"filterCli": "CLI 앱",
|
||||
"filterMcp": "MCP 서비스",
|
||||
"enabledSummary": "{{count}}개 활성화됨",
|
||||
"caption": "{{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "앱 검색",
|
||||
"featured": "추천",
|
||||
"loading": "앱 불러오는 중...",
|
||||
"empty": "이 필터와 일치하는 앱이 없습니다."
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@@ -607,10 +624,10 @@
|
||||
"io": "이 파일을 읽을 수 없습니다"
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "앱 및 MCP",
|
||||
"label": "플러그인",
|
||||
"ariaLabel": "앱",
|
||||
"label": "앱",
|
||||
"cliGroup": "CLI 앱",
|
||||
"mcpGroup": "MCP 서버",
|
||||
"mcpGroup": "MCP 서비스",
|
||||
"cliBadge": "CLI",
|
||||
"mcpBadge": "MCP",
|
||||
"cliDescription": "@{{name}}을 로컬 CLI 앱으로 사용",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "Ngôn ngữ",
|
||||
"ariaLabel": "Đổi ngôn ngữ"
|
||||
}
|
||||
},
|
||||
"apps": "Ứng dụng"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "Quay lại trò chuyện",
|
||||
@@ -83,7 +84,8 @@
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "Ứng dụng CLI",
|
||||
"mcp": "MCP"
|
||||
"mcp": "MCP",
|
||||
"apps": "Ứng dụng"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "Giao diện",
|
||||
@@ -101,7 +103,8 @@
|
||||
"capabilities": "Khả năng",
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "Ứng dụng CLI",
|
||||
"mcp": "MCP"
|
||||
"mcp": "Dịch vụ MCP",
|
||||
"apps": "Ứng dụng"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "Giao diện",
|
||||
@@ -380,6 +383,20 @@
|
||||
},
|
||||
"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 CLI ứng dụng và dịch vụ MCP để nanobot dùng trong trò chuyện.",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"filterAll": "Tất cả",
|
||||
"filterCli": "Ứng dụng CLI",
|
||||
"filterMcp": "Dịch vụ MCP",
|
||||
"enabledSummary": "{{count}} đã bật",
|
||||
"caption": "{{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "Tìm ứng dụng",
|
||||
"featured": "Nổi bật",
|
||||
"loading": "Đang tải ứng dụng...",
|
||||
"empty": "Không có ứng dụng nào khớp với bộ lọc này."
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@@ -607,10 +624,10 @@
|
||||
"io": "Không thể đọc tệp này"
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "Ứng dụng và MCP",
|
||||
"label": "Plugin",
|
||||
"ariaLabel": "Ứng dụng",
|
||||
"label": "Ứng dụng",
|
||||
"cliGroup": "Ứng dụng CLI",
|
||||
"mcpGroup": "Máy chủ MCP",
|
||||
"mcpGroup": "Dịch vụ MCP",
|
||||
"cliBadge": "CLI",
|
||||
"mcpBadge": "MCP",
|
||||
"cliDescription": "Dùng @{{name}} như ứng dụng CLI cục bộ",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "语言",
|
||||
"ariaLabel": "切换语言"
|
||||
}
|
||||
},
|
||||
"apps": "应用"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "返回对话",
|
||||
@@ -83,7 +84,8 @@
|
||||
"cliApps": "CLI 应用",
|
||||
"mcp": "MCP",
|
||||
"runtime": "运行时",
|
||||
"advanced": "高级"
|
||||
"advanced": "高级",
|
||||
"apps": "应用"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "界面",
|
||||
@@ -97,11 +99,12 @@
|
||||
"webSearch": "网页搜索",
|
||||
"webBehavior": "行为",
|
||||
"cliApps": "CLI 应用",
|
||||
"mcp": "MCP",
|
||||
"mcp": "MCP 服务",
|
||||
"identity": "身份",
|
||||
"safety": "安全",
|
||||
"capabilities": "能力",
|
||||
"integrations": "集成"
|
||||
"integrations": "集成",
|
||||
"apps": "应用"
|
||||
},
|
||||
"models": {
|
||||
"selectModel": "选择模型",
|
||||
@@ -380,6 +383,20 @@
|
||||
"selectSize": "选择尺寸",
|
||||
"configureProvider": "配置服务商",
|
||||
"missingCredential": "启用图片生成前,请先配置这个服务商。"
|
||||
},
|
||||
"apps": {
|
||||
"description": "添加 nanobot 可在聊天中使用的 App CLI 和 MCP 服务。",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"filterAll": "全部",
|
||||
"filterCli": "CLI 应用",
|
||||
"filterMcp": "MCP 服务",
|
||||
"enabledSummary": "已启用 {{count}} 个",
|
||||
"caption": "{{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "搜索应用",
|
||||
"featured": "精选",
|
||||
"loading": "正在加载应用...",
|
||||
"empty": "没有符合筛选条件的应用。"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@@ -595,8 +612,8 @@
|
||||
}
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "应用和 MCP",
|
||||
"label": "插件",
|
||||
"ariaLabel": "应用",
|
||||
"label": "应用",
|
||||
"cliGroup": "CLI 应用",
|
||||
"mcpGroup": "MCP 服务",
|
||||
"cliBadge": "CLI",
|
||||
|
||||
@@ -63,7 +63,8 @@
|
||||
"language": {
|
||||
"label": "語言",
|
||||
"ariaLabel": "切換語言"
|
||||
}
|
||||
},
|
||||
"apps": "應用"
|
||||
},
|
||||
"settings": {
|
||||
"backToChat": "返回對話",
|
||||
@@ -83,7 +84,8 @@
|
||||
"runtime": "Runtime",
|
||||
"advanced": "Advanced",
|
||||
"cliApps": "CLI 應用",
|
||||
"mcp": "MCP"
|
||||
"mcp": "MCP",
|
||||
"apps": "應用"
|
||||
},
|
||||
"sections": {
|
||||
"interface": "介面",
|
||||
@@ -101,7 +103,8 @@
|
||||
"capabilities": "功能",
|
||||
"integrations": "Integrations",
|
||||
"cliApps": "CLI 應用",
|
||||
"mcp": "MCP"
|
||||
"mcp": "MCP 服務",
|
||||
"apps": "應用"
|
||||
},
|
||||
"rows": {
|
||||
"theme": "主題",
|
||||
@@ -380,6 +383,20 @@
|
||||
},
|
||||
"legal": {
|
||||
"thirdPartyBrands": "產品名稱、標誌與品牌均屬於其各自擁有者。使用僅為識別用途,並不代表背書。"
|
||||
},
|
||||
"apps": {
|
||||
"description": "新增 nanobot 可在聊天中使用的 App CLI 與 MCP 服務。",
|
||||
"cliLabel": "CLI",
|
||||
"mcpLabel": "MCP",
|
||||
"filterAll": "全部",
|
||||
"filterCli": "CLI 應用",
|
||||
"filterMcp": "MCP 服務",
|
||||
"enabledSummary": "已啟用 {{count}} 個",
|
||||
"caption": "{{cli}} CLI · {{mcp}} MCP",
|
||||
"searchPlaceholder": "搜尋應用",
|
||||
"featured": "精選",
|
||||
"loading": "正在載入應用...",
|
||||
"empty": "沒有符合篩選條件的應用。"
|
||||
}
|
||||
},
|
||||
"chat": {
|
||||
@@ -607,8 +624,8 @@
|
||||
"io": "無法讀取這個檔案"
|
||||
},
|
||||
"mentions": {
|
||||
"ariaLabel": "應用和 MCP",
|
||||
"label": "插件",
|
||||
"ariaLabel": "應用",
|
||||
"label": "應用",
|
||||
"cliGroup": "CLI 應用",
|
||||
"mcpGroup": "MCP 服務",
|
||||
"cliBadge": "CLI",
|
||||
|
||||
@@ -280,6 +280,59 @@ export interface SettingsPayload {
|
||||
restart_required_sections?: Array<"runtime" | "web" | "image">;
|
||||
}
|
||||
|
||||
export interface AppPackageRef {
|
||||
manager: string;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
export interface AppCapability {
|
||||
type: "cli" | "mcp" | "skill" | string;
|
||||
entry_point?: string;
|
||||
package?: AppPackageRef;
|
||||
path?: string;
|
||||
transport?: string;
|
||||
command?: string;
|
||||
args?: string[];
|
||||
url?: string;
|
||||
fields?: Array<{
|
||||
name: string;
|
||||
target?: string;
|
||||
required?: boolean;
|
||||
secret?: boolean;
|
||||
env_var?: string | null;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface AppPlan {
|
||||
supported: boolean;
|
||||
strategy?: string;
|
||||
managed_paths?: string[];
|
||||
verification?: string[];
|
||||
}
|
||||
|
||||
export interface AppTrust {
|
||||
registry: string;
|
||||
level: string;
|
||||
review_status: string;
|
||||
}
|
||||
|
||||
export interface AppManifest {
|
||||
schema: "agent-app.v1" | string;
|
||||
id: string;
|
||||
display_name: string;
|
||||
version?: string;
|
||||
description: string;
|
||||
category: string;
|
||||
source: string;
|
||||
logo_url?: string | null;
|
||||
brand_color?: string | null;
|
||||
docs_url?: string | null;
|
||||
capabilities: AppCapability[];
|
||||
install: AppPlan;
|
||||
remove: AppPlan;
|
||||
trust: AppTrust;
|
||||
}
|
||||
|
||||
export interface CliAppInfo {
|
||||
name: string;
|
||||
display_name: string;
|
||||
@@ -295,6 +348,7 @@ export interface CliAppInfo {
|
||||
logo_url?: string | null;
|
||||
brand_color?: string | null;
|
||||
skill_installed: boolean;
|
||||
manifest?: AppManifest;
|
||||
}
|
||||
|
||||
export interface CliAppsPayload {
|
||||
@@ -304,7 +358,12 @@ export interface CliAppsPayload {
|
||||
last_action?: {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
installed?: boolean;
|
||||
removed?: boolean;
|
||||
output?: string | null;
|
||||
still_available?: boolean;
|
||||
verification?: string[];
|
||||
verification_failed?: string[];
|
||||
};
|
||||
}
|
||||
|
||||
@@ -342,6 +401,7 @@ export interface McpPresetInfo {
|
||||
error?: string | null;
|
||||
enabled_tools?: string[];
|
||||
source?: "preset" | "custom" | string;
|
||||
manifest?: AppManifest;
|
||||
}
|
||||
|
||||
export interface McpPresetsPayload {
|
||||
@@ -364,6 +424,11 @@ export interface McpPresetsPayload {
|
||||
last_action?: {
|
||||
ok: boolean;
|
||||
message: string;
|
||||
installed?: boolean;
|
||||
removed?: boolean;
|
||||
managed_paths_removed?: string[];
|
||||
verification?: string[];
|
||||
verification_failed?: string[];
|
||||
tool_count?: number;
|
||||
tool_names?: string[];
|
||||
checked_at?: string | null;
|
||||
|
||||
@@ -13,6 +13,100 @@ const attachSpy = vi.fn();
|
||||
const runStatusHandlers = new Set<(chatId: string, startedAt: number | null) => void>();
|
||||
let mockSessions: ChatSummary[] = [];
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function baseSettingsPayload() {
|
||||
return {
|
||||
agent: {
|
||||
model: "openai/gpt-4o",
|
||||
provider: "auto",
|
||||
resolved_provider: "openai",
|
||||
has_api_key: true,
|
||||
model_preset: "default",
|
||||
max_tokens: 8192,
|
||||
context_window_tokens: 65536,
|
||||
temperature: 0.1,
|
||||
reasoning_effort: null,
|
||||
timezone: "UTC",
|
||||
bot_name: "nanobot",
|
||||
bot_icon: "nb",
|
||||
tool_hint_max_length: 40,
|
||||
},
|
||||
model_presets: [{
|
||||
name: "default",
|
||||
label: "Default",
|
||||
active: true,
|
||||
is_default: true,
|
||||
model: "openai/gpt-4o",
|
||||
provider: "auto",
|
||||
max_tokens: 8192,
|
||||
context_window_tokens: 65536,
|
||||
temperature: 0.1,
|
||||
reasoning_effort: null,
|
||||
}],
|
||||
providers: [],
|
||||
web_search: {
|
||||
provider: "duckduckgo",
|
||||
api_key_hint: null,
|
||||
base_url: null,
|
||||
max_results: 5,
|
||||
timeout: 30,
|
||||
providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }],
|
||||
},
|
||||
web: {
|
||||
enable: true,
|
||||
proxy: null,
|
||||
user_agent: null,
|
||||
search: { max_results: 5, timeout: 30 },
|
||||
fetch: { use_jina_reader: true },
|
||||
},
|
||||
image_generation: {
|
||||
enabled: false,
|
||||
provider: "openrouter",
|
||||
provider_configured: false,
|
||||
model: "openai/gpt-5.4-image-2",
|
||||
default_aspect_ratio: "1:1",
|
||||
default_image_size: "1K",
|
||||
max_images_per_turn: 4,
|
||||
save_dir: "generated",
|
||||
providers: [],
|
||||
},
|
||||
runtime: {
|
||||
config_path: "/tmp/config.json",
|
||||
workspace_path: "/tmp/workspace",
|
||||
gateway_host: "127.0.0.1",
|
||||
gateway_port: 18790,
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
interval_s: 1800,
|
||||
keep_recent_messages: 8,
|
||||
},
|
||||
dream: {
|
||||
schedule: "every 2h",
|
||||
max_batch_size: 20,
|
||||
max_iterations: 15,
|
||||
annotate_line_ages: true,
|
||||
},
|
||||
unified_session: false,
|
||||
},
|
||||
advanced: {
|
||||
restrict_to_workspace: false,
|
||||
ssrf_whitelist_count: 0,
|
||||
mcp_server_count: 0,
|
||||
exec_enabled: true,
|
||||
exec_sandbox: null,
|
||||
exec_path_append_set: false,
|
||||
},
|
||||
requires_restart: false,
|
||||
};
|
||||
}
|
||||
|
||||
vi.mock("@/hooks/useSessions", async (importOriginal) => {
|
||||
const React = await import("react");
|
||||
const actual = await importOriginal<typeof import("@/hooks/useSessions")>();
|
||||
@@ -709,6 +803,9 @@ describe("App layout", () => {
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const searchButton = within(sidebar).getByRole("button", { name: "Search" });
|
||||
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
|
||||
expect(searchButton.compareDocumentPosition(appsButton) & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
|
||||
fireEvent.click(within(sidebar).getByRole("button", { name: "Settings" }));
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Overview" })).toBeInTheDocument();
|
||||
@@ -731,6 +828,7 @@ describe("App layout", () => {
|
||||
expect(within(settingsNav).queryByRole("button", { name: "Providers" })).not.toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Image" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Web" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Apps" })).toBeInTheDocument();
|
||||
expect(within(settingsNav).getByRole("button", { name: "Advanced" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("button", { name: "Sign out" })).toBeInTheDocument();
|
||||
fireEvent.click(within(settingsNav).getByRole("button", { name: "Appearance" }));
|
||||
@@ -822,6 +920,42 @@ describe("App layout", () => {
|
||||
expect(screen.getByRole("button", { name: "Save" })).toBeEnabled();
|
||||
});
|
||||
|
||||
it("opens Apps from the main sidebar without replacing the sidebar", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: RequestInfo | URL) => {
|
||||
const href = String(input);
|
||||
if (href === "/api/settings") {
|
||||
return jsonResponse(baseSettingsPayload());
|
||||
}
|
||||
if (href === "/api/settings/cli-apps") {
|
||||
return jsonResponse({ apps: [], installed_count: 0, catalog_updated_at: "2026-04-18" });
|
||||
}
|
||||
if (href === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
}),
|
||||
);
|
||||
|
||||
render(<App />);
|
||||
|
||||
await waitFor(() => expect(connectSpy).toHaveBeenCalled());
|
||||
const sidebar = screen.getByRole("navigation", { name: "Sidebar navigation" });
|
||||
const appsButton = within(sidebar).getByRole("button", { name: "Apps" });
|
||||
|
||||
fireEvent.click(appsButton);
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Apps" })).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(
|
||||
"aria-current",
|
||||
"page",
|
||||
);
|
||||
expect(document.title).toBe("Apps · nanobot");
|
||||
});
|
||||
|
||||
it("returns from settings to the blank start page when no session was active", async () => {
|
||||
mockSessions = [
|
||||
{
|
||||
|
||||
@@ -28,6 +28,7 @@ const SETTINGS_NAV_KEYS = [
|
||||
"models",
|
||||
"image",
|
||||
"web",
|
||||
"apps",
|
||||
"runtime",
|
||||
"advanced",
|
||||
];
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { SettingsView } from "@/components/settings/SettingsView";
|
||||
import { ClientProvider } from "@/providers/ClientProvider";
|
||||
|
||||
function jsonResponse(body: unknown): Response {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => body,
|
||||
} as Response;
|
||||
}
|
||||
|
||||
function settingsPayload() {
|
||||
return {
|
||||
agent: {
|
||||
model: "openai/gpt-4o",
|
||||
provider: "auto",
|
||||
resolved_provider: "openai",
|
||||
has_api_key: true,
|
||||
model_preset: "default",
|
||||
max_tokens: 8192,
|
||||
context_window_tokens: 65536,
|
||||
temperature: 0.1,
|
||||
reasoning_effort: null,
|
||||
timezone: "UTC",
|
||||
bot_name: "nanobot",
|
||||
bot_icon: "nb",
|
||||
tool_hint_max_length: 40,
|
||||
},
|
||||
model_presets: [{
|
||||
name: "default",
|
||||
label: "Default",
|
||||
active: true,
|
||||
is_default: true,
|
||||
model: "openai/gpt-4o",
|
||||
provider: "auto",
|
||||
max_tokens: 8192,
|
||||
context_window_tokens: 65536,
|
||||
temperature: 0.1,
|
||||
reasoning_effort: null,
|
||||
}],
|
||||
providers: [],
|
||||
web_search: {
|
||||
provider: "duckduckgo",
|
||||
api_key_hint: null,
|
||||
base_url: null,
|
||||
max_results: 5,
|
||||
timeout: 30,
|
||||
providers: [{ name: "duckduckgo", label: "DuckDuckGo", credential: "none" }],
|
||||
},
|
||||
web: {
|
||||
enable: true,
|
||||
proxy: null,
|
||||
user_agent: null,
|
||||
search: { max_results: 5, timeout: 30 },
|
||||
fetch: { use_jina_reader: true },
|
||||
},
|
||||
image_generation: {
|
||||
enabled: false,
|
||||
provider: "openrouter",
|
||||
provider_configured: false,
|
||||
model: "openai/gpt-5.4-image-2",
|
||||
default_aspect_ratio: "1:1",
|
||||
default_image_size: "1K",
|
||||
max_images_per_turn: 4,
|
||||
save_dir: "generated",
|
||||
providers: [],
|
||||
},
|
||||
runtime: {
|
||||
config_path: "/tmp/config.json",
|
||||
workspace_path: "/tmp/workspace",
|
||||
gateway_host: "127.0.0.1",
|
||||
gateway_port: 18790,
|
||||
heartbeat: {
|
||||
enabled: true,
|
||||
interval_s: 1800,
|
||||
keep_recent_messages: 8,
|
||||
},
|
||||
dream: {
|
||||
schedule: "every 2h",
|
||||
max_batch_size: 20,
|
||||
max_iterations: 15,
|
||||
annotate_line_ages: true,
|
||||
},
|
||||
unified_session: false,
|
||||
},
|
||||
advanced: {
|
||||
restrict_to_workspace: false,
|
||||
ssrf_whitelist_count: 0,
|
||||
mcp_server_count: 0,
|
||||
exec_enabled: true,
|
||||
exec_sandbox: null,
|
||||
exec_path_append_set: false,
|
||||
},
|
||||
requires_restart: false,
|
||||
};
|
||||
}
|
||||
|
||||
const installedAnyGen = {
|
||||
name: "anygen",
|
||||
display_name: "AnyGen",
|
||||
category: "generation",
|
||||
description: "Generate docs, slides, websites and more via AnyGen cloud API",
|
||||
requires: "ANYGEN_API_KEY",
|
||||
source: "harness",
|
||||
entry_point: "cli-anything-anygen",
|
||||
install_supported: true,
|
||||
installed: true,
|
||||
available: true,
|
||||
status: "installed",
|
||||
logo_url: "https://www.google.com/s2/favicons?domain=anygen.io&sz=64",
|
||||
brand_color: "#111827",
|
||||
skill_installed: true,
|
||||
};
|
||||
|
||||
function renderSettingsView() {
|
||||
render(
|
||||
<ClientProvider client={{} as never} token="tok">
|
||||
<SettingsView
|
||||
theme="light"
|
||||
initialSection="apps"
|
||||
onToggleTheme={() => {}}
|
||||
onBackToChat={() => {}}
|
||||
onModelNameChange={() => {}}
|
||||
/>
|
||||
</ClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("SettingsView Apps catalog", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("shows a visible uninstall button for installed CLI apps and calls uninstall", async () => {
|
||||
const fetchMock = 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,
|
||||
catalog_updated_at: "2026-04-18",
|
||||
});
|
||||
}
|
||||
if (url === "/api/settings/mcp-presets") {
|
||||
return jsonResponse({ presets: [], installed_count: 0 });
|
||||
}
|
||||
if (url === "/api/settings/cli-apps/uninstall?name=anygen") {
|
||||
return jsonResponse({
|
||||
apps: [{ ...installedAnyGen, installed: false, status: "available" }],
|
||||
installed_count: 0,
|
||||
catalog_updated_at: "2026-04-18",
|
||||
last_action: {
|
||||
ok: true,
|
||||
message: "Uninstalled CLI for AnyGen.",
|
||||
still_available: false,
|
||||
},
|
||||
});
|
||||
}
|
||||
return { ok: false, status: 404, json: async () => ({}) } as Response;
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
renderSettingsView();
|
||||
|
||||
expect(await screen.findByRole("heading", { name: "Apps" })).toBeInTheDocument();
|
||||
expect(await screen.findByText("AnyGen")).toBeInTheDocument();
|
||||
const uninstall = screen.getByRole("button", { name: "Uninstall CLI" });
|
||||
|
||||
fireEvent.click(uninstall);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"/api/settings/cli-apps/uninstall?name=anygen",
|
||||
expect.objectContaining({
|
||||
headers: { Authorization: "Bearer tok" },
|
||||
}),
|
||||
),
|
||||
);
|
||||
expect(await screen.findByText("Uninstalled CLI for AnyGen.")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Dismiss" }));
|
||||
|
||||
expect(screen.queryByText("Uninstalled CLI for AnyGen.")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -346,7 +346,7 @@ describe("ThreadComposer", () => {
|
||||
const input = screen.getByLabelText("Message input");
|
||||
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||
|
||||
const palette = screen.getByRole("listbox", { name: "Apps and MCP" });
|
||||
const palette = screen.getByRole("listbox", { name: "Apps" });
|
||||
expect(palette).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: /@gimp/i })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
@@ -365,7 +365,7 @@ describe("ThreadComposer", () => {
|
||||
expect(screen.getByTestId("composer-cli-mention-blender")).toHaveTextContent("@blender");
|
||||
expect(screen.queryByTestId("composer-cli-app-tray")).not.toBeInTheDocument();
|
||||
expect(onSend).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("listbox", { name: "Apps and MCP" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("listbox", { name: "Apps" })).not.toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Send message" }));
|
||||
|
||||
|
||||
@@ -1088,7 +1088,7 @@ describe("ThreadShell", () => {
|
||||
));
|
||||
|
||||
const input = await screen.findByLabelText("Message input");
|
||||
expect(screen.queryByRole("listbox", { name: "Apps and MCP" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("listbox", { name: "Apps" })).not.toBeInTheDocument();
|
||||
|
||||
const payload: CliAppsPayload = {
|
||||
apps: [{
|
||||
@@ -1116,7 +1116,7 @@ describe("ThreadShell", () => {
|
||||
});
|
||||
fireEvent.change(input, { target: { value: "@", selectionStart: 1 } });
|
||||
|
||||
expect(screen.getByRole("listbox", { name: "Apps and MCP" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("listbox", { name: "Apps" })).toBeInTheDocument();
|
||||
expect(screen.getByRole("option", { name: /@gimp/i })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user