diff --git a/README.md b/README.md index 3935bc6c..59e9ccaa 100644 --- a/README.md +++ b/README.md @@ -31,7 +31,7 @@

- Every supported agent shows on the Overview by default, installed or not — to hide one, go to Settings → Agent Paths and click its "Enabled" toggle to switch it off. + Every supported agent shows on the Overview by default, installed or not. In Settings → Agent Paths, switch to "Detected only" to hide and disable undetected agents, or flip a single agent's "Enabled" toggle off.


diff --git a/README.zh-CN.md b/README.zh-CN.md index d699ea85..320761b3 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -31,7 +31,7 @@

- 「概览」页默认展示所有受支持的 Agent,无论是否已安装 —— 想隐藏某个 Agent,可在「设置 → 智能体路径」点它的「已启用」开关将其关闭。 + 「概览」页默认展示所有受支持的 Agent,无论是否已安装。在「设置 → 智能体路径」切到「仅已检测到」即可隐藏并禁用未检测到的 Agent,或单独关闭某个 Agent 的「已启用」开关。


diff --git a/src/App.tsx b/src/App.tsx index 955207bf..d6902cad 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -17,6 +17,7 @@ import KitsPage from "./pages/kits"; import MarketplacePage from "./pages/marketplace"; import OverviewPage from "./pages/overview"; import SettingsPage from "./pages/settings"; +import { useAgentStore } from "./stores/agent-store"; import { useAuditStore } from "./stores/audit-store"; import { useExtensionStore } from "./stores/extension-store"; import { resolveMode, useUIStore } from "./stores/ui-store"; @@ -36,6 +37,8 @@ export default function App() { const [showConfetti, setShowConfetti] = useState(false); const lastScanRef = useRef(0); const appIcon = useUIStore((s) => s.appIcon); + const agents = useAgentStore((s) => s.agents); + const agentVisibility = useUIStore((s) => s.agentVisibility); // Track resolved dark/light (reacts to OS changes when mode === "system") const [resolved, setResolved] = useState<"dark" | "light">(() => @@ -53,6 +56,22 @@ export default function App() { return () => mq.removeEventListener("change", onChange); }, [mode]); + // Keep "Detected only" honest over time: undetected agents must stay disabled + // — including ones added by a later app update while the user stays in this + // mode (the per-click handler in Settings can't catch those). Reconcile + // whenever the agent list or visibility changes. Converges: once disabled an + // agent is no longer "enabled", so the next run finds nothing to do. + useEffect(() => { + if (agentVisibility !== "detected") return; + const stray = agents + .filter((a) => !a.detected && a.enabled) + .map((a) => a.name); + if (stray.length === 0) return; + useAgentStore.getState().setEnabledBulk(stray, false); + const { autoDisabledAgents, setAutoDisabledAgents } = useUIStore.getState(); + setAutoDisabledAgents([...new Set([...autoDisabledAgents, ...stray])]); + }, [agents, agentVisibility]); + // Check for updates on startup (non-blocking, silent failure). // Desktop uses Tauri's native updater; web mode polls GitHub releases. useEffect(() => { diff --git a/src/components/agents/agent-detail.tsx b/src/components/agents/agent-detail.tsx index 58979623..f5b768fb 100644 --- a/src/components/agents/agent-detail.tsx +++ b/src/components/agents/agent-detail.tsx @@ -13,6 +13,7 @@ import { } from "@/lib/types"; import { useAgentConfigStore } from "@/stores/agent-config-store"; import { useExtensionStore } from "@/stores/extension-store"; +import { useUIStore } from "@/stores/ui-store"; import { ConfigSection } from "./config-section"; import { ExtensionsSummaryCard } from "./extensions-summary-card"; import { SectionAnchorRail } from "./section-anchor-rail"; @@ -26,6 +27,7 @@ export function AgentDetail() { const allExtensions = useExtensionStore((s) => s.extensions); const { scope } = useScope(); const agent = agentDetails.find((a) => a.name === selectedAgent); + const agentVisibility = useUIStore((s) => s.agentVisibility); const [showAddForm, setShowAddForm] = useState(false); const [customPath, setCustomPath] = useState(""); @@ -62,7 +64,9 @@ export function AgentDetail() { if (!agent) { return (
- {t("detail.selectAgent")} + {agentVisibility === "detected" && !agentDetails.some((a) => a.detected) + ? t("list.noDetected") + : t("detail.selectAgent")}
); } diff --git a/src/components/agents/agent-list.tsx b/src/components/agents/agent-list.tsx index 23db2577..b808c4e4 100644 --- a/src/components/agents/agent-list.tsx +++ b/src/components/agents/agent-list.tsx @@ -18,13 +18,14 @@ import { import { CSS } from "@dnd-kit/utilities"; import { clsx } from "clsx"; import { GripVertical } from "lucide-react"; -import { useCallback, useMemo } from "react"; +import { useCallback, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { AgentMascot } from "@/components/shared/agent-mascot/agent-mascot"; import type { AgentDetail } from "@/lib/types"; import { agentDisplayName } from "@/lib/types"; import { useAgentConfigStore } from "@/stores/agent-config-store"; import { useAgentStore } from "@/stores/agent-store"; +import { useUIStore } from "@/stores/ui-store"; function SortableAgentItem({ agent, @@ -99,6 +100,7 @@ export function AgentList() { const selectAgent = useAgentConfigStore((s) => s.selectAgent); const agentOrder = useAgentStore((s) => s.agentOrder); const reorderAgents = useAgentStore((s) => s.reorderAgents); + const agentVisibility = useUIStore((s) => s.agentVisibility); const sorted = useMemo( () => @@ -110,6 +112,37 @@ export function AgentList() { [agentDetails, agentOrder], ); + const visible = useMemo( + () => + agentVisibility === "detected" + ? sorted.filter((a) => a.detected) + : sorted, + [sorted, agentVisibility], + ); + + const hidden = useMemo( + () => + new Set( + agentVisibility === "detected" + ? sorted.filter((a) => !a.detected).map((a) => a.name) + : [], + ), + [sorted, agentVisibility], + ); + + useEffect(() => { + if (agentVisibility !== "detected") return; + if (!selectedAgent) return; + const isSelectedVisible = visible.some((a) => a.name === selectedAgent); + if (!isSelectedVisible) { + if (visible.length > 0) { + selectAgent(visible[0].name); + } else { + selectAgent(null); + } + } + }, [agentVisibility, visible, selectedAgent, selectAgent]); + const sensors = useSensors( useSensor(PointerSensor, { activationConstraint: { distance: 5 } }), useSensor(KeyboardSensor, { @@ -122,16 +155,54 @@ export function AgentList() { const { active, over } = event; if (!over || active.id === over.id) return; - const names = sorted.map((a) => a.name); - const oldIndex = names.indexOf(active.id as string); - const newIndex = names.indexOf(over.id as string); + const visibleNames = visible.map((a) => a.name); + const oldIndex = visibleNames.indexOf(active.id as string); + const newIndex = visibleNames.indexOf(over.id as string); if (oldIndex === -1 || newIndex === -1) return; - reorderAgents(arrayMove(names, oldIndex, newIndex)); + const reorderedVisible = arrayMove(visibleNames, oldIndex, newIndex); + + let fullOrder: string[]; + if (hidden.size === 0) { + fullOrder = reorderedVisible; + } else { + const result: string[] = []; + let vi = 0; + for (const name of agentOrder) { + if (hidden.has(name)) { + result.push(name); + } else { + if (vi < reorderedVisible.length) { + result.push(reorderedVisible[vi]); + vi++; + } + } + } + while (vi < reorderedVisible.length) { + result.push(reorderedVisible[vi]); + vi++; + } + fullOrder = result; + } + + reorderAgents(fullOrder); }, - [sorted, reorderAgents], + [visible, hidden, agentOrder, reorderAgents], ); + if (visible.length === 0) { + return ( +
+
+ {t("list.header")} +
+
+ {t("list.noDetected")} +
+
+ ); + } + return (
@@ -144,10 +215,10 @@ export function AgentList() { onDragEnd={handleDragEnd} > a.name)} + items={visible.map((a) => a.name)} strategy={verticalListSortingStrategy} > - {sorted.map((agent) => ( + {visible.map((agent) => ( s.agents); const agentOrder = useAgentStore((s) => s.agentOrder); + const agentVisibility = useUIStore((s) => s.agentVisibility); const enabledAgents = useMemo( () => sortAgents( - agents.filter((a) => a.enabled), + agents.filter((a) => + agentVisibility === "detected" ? a.enabled && a.detected : a.enabled, + ), agentOrder, ), - [agents, agentOrder], + [agents, agentOrder, agentVisibility], ); const resultCount = filtered().length; - // Clear packFilter when the selected pack no longer exists in the current - // scope — otherwise the dropdown shows a stale value not in options and - // results read empty. useEffect(() => { if (packFilter && !scopedPacks.includes(packFilter)) { setPackFilter(null); } }, [packFilter, scopedPacks, setPackFilter]); + useEffect(() => { + if (agentFilter && !enabledAgents.some((a) => a.name === agentFilter)) { + setAgentFilter(null); + } + }, [agentFilter, enabledAgents, setAgentFilter]); + return (
{/* Filters: kind pills + result count + dropdowns + search */} diff --git a/src/lib/i18n/locales/en/agents.json b/src/lib/i18n/locales/en/agents.json index 51f91fd7..b3a62802 100644 --- a/src/lib/i18n/locales/en/agents.json +++ b/src/lib/i18n/locales/en/agents.json @@ -1,7 +1,8 @@ { "list": { "header": "Agents", - "notDetected": "Not detected" + "notDetected": "Not detected", + "noDetected": "No detected agents" }, "detail": { "selectAgent": "Select an agent to view its configuration", @@ -45,7 +46,8 @@ "pathUpdateFailed": "Failed to update {{agent}} path", "agentEnabled": "{{agent}} enabled", "agentDisabled": "{{agent}} disabled", - "updateFailed": "Failed to update {{agent}}" + "updateFailed": "Failed to update {{agent}}", + "bulkUpdateFailed": "Failed to update some agents" }, "summary": { "title": "Extensions", diff --git a/src/lib/i18n/locales/en/settings.json b/src/lib/i18n/locales/en/settings.json index 4d59c29b..217452c0 100644 --- a/src/lib/i18n/locales/en/settings.json +++ b/src/lib/i18n/locales/en/settings.json @@ -25,6 +25,11 @@ "agentPaths": { "section": "Agent Paths", "description": "Auto-detected paths shown below. Click the edit button to choose a custom path.", + "visibilityAll": "All agents", + "visibilityDetected": "Detected only", + "visibilityToast": "Visibility: {{label}}", + "visibilityHint": "Detected only disables undetected agents.", + "lockedHint": "Switch to \"All agents\" to change this.", "enabled": "Enabled", "disabled": "Disabled", "notDetected": "Not detected", diff --git a/src/lib/i18n/locales/zh/agents.json b/src/lib/i18n/locales/zh/agents.json index 095470da..2cfcde51 100644 --- a/src/lib/i18n/locales/zh/agents.json +++ b/src/lib/i18n/locales/zh/agents.json @@ -1,7 +1,8 @@ { "list": { "header": "智能体", - "notDetected": "未检测到" + "notDetected": "未检测到", + "noDetected": "未检测到智能体" }, "detail": { "selectAgent": "选择一个智能体以查看其配置", @@ -45,7 +46,8 @@ "pathUpdateFailed": "更新 {{agent}} 路径失败", "agentEnabled": "已启用 {{agent}}", "agentDisabled": "已禁用 {{agent}}", - "updateFailed": "更新 {{agent}} 失败" + "updateFailed": "更新 {{agent}} 失败", + "bulkUpdateFailed": "部分智能体更新失败" }, "summary": { "title": "扩展", diff --git a/src/lib/i18n/locales/zh/settings.json b/src/lib/i18n/locales/zh/settings.json index 83288a53..159a911d 100644 --- a/src/lib/i18n/locales/zh/settings.json +++ b/src/lib/i18n/locales/zh/settings.json @@ -25,6 +25,11 @@ "agentPaths": { "section": "智能体路径", "description": "下方显示自动检测到的路径。点击编辑按钮可选择自定义路径。", + "visibilityAll": "全部智能体", + "visibilityDetected": "仅已检测到", + "visibilityToast": "可见性:{{label}}", + "visibilityHint": "切换“仅已检测到”会禁用未检测到的智能体。", + "lockedHint": "切回“全部智能体”才能修改。", "enabled": "已启用", "disabled": "已禁用", "notDetected": "未检测到", diff --git a/src/pages/overview.tsx b/src/pages/overview.tsx index db5314b8..a81965b4 100644 --- a/src/pages/overview.tsx +++ b/src/pages/overview.tsx @@ -27,6 +27,7 @@ import { useAgentStore } from "@/stores/agent-store"; import { useAuditStore } from "@/stores/audit-store"; import { buildGroups, useExtensionStore } from "@/stores/extension-store"; import { toast } from "@/stores/toast-store"; +import { useUIStore } from "@/stores/ui-store"; // --------------------------------------------------------------------------- // Tip of the Day types & helpers @@ -199,6 +200,7 @@ export default function OverviewPage() { const agents = useAgentStore((s) => s.agents); const fetchAgents = useAgentStore((s) => s.fetch); const agentOrder = useAgentStore((s) => s.agentOrder); + const agentVisibility = useUIStore((s) => s.agentVisibility); const [agentConfigs, setAgentConfigs] = useState([]); const [auditLoading, setAuditLoading] = useState(false); @@ -315,14 +317,18 @@ export default function OverviewPage() { () => sortAgents( agents - .filter((a) => a.enabled) + .filter((a) => + agentVisibility === "detected" + ? a.enabled && a.detected + : a.enabled, + ) .map((a) => ({ ...a, extension_count: agentExtCounts.get(a.name) ?? 0, })), agentOrder, ), - [agents, agentExtCounts, agentOrder], + [agents, agentExtCounts, agentOrder, agentVisibility], ); // ----------------------------------------------------------------------- diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 8dc05a5c..0c449f39 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -27,7 +27,7 @@ import { agentDisplayName, type DiscoveredProject } from "@/lib/types"; import { useAgentStore } from "@/stores/agent-store"; import { useProjectStore } from "@/stores/project-store"; import { toast } from "@/stores/toast-store"; -import type { AppIcon, ThemeName } from "@/stores/ui-store"; +import type { AgentVisibility, AppIcon, ThemeName } from "@/stores/ui-store"; import { useUIStore } from "@/stores/ui-store"; import { useUpdateStore } from "@/stores/update-store"; import { useWebUpdateStore } from "@/stores/web-update-store"; @@ -71,6 +71,14 @@ const LANGUAGE_OPTIONS: { { value: "zh", labelKey: "language.zh" }, ]; +const AGENT_VISIBILITY_OPTIONS: { + value: AgentVisibility; + labelKey: "agentPaths.visibilityAll" | "agentPaths.visibilityDetected"; +}[] = [ + { value: "all", labelKey: "agentPaths.visibilityAll" }, + { value: "detected", labelKey: "agentPaths.visibilityDetected" }, +]; + function UpdateSection() { const { t } = useTranslation("settings"); const available = useUpdateStore((s) => s.available); @@ -172,9 +180,13 @@ export default function SettingsPage() { themeName, mode, appIcon, + agentVisibility, + autoDisabledAgents, setThemeName, setMode, setAppIcon: setAppIconState, + setAgentVisibility, + setAutoDisabledAgents, } = useUIStore(); const { projects, loading, loadProjects, addProject, removeProject } = useProjectStore(); @@ -184,6 +196,7 @@ export default function SettingsPage() { fetch: fetchAgents, updatePath, setEnabled, + setEnabledBulk, } = useAgentStore(); const [searchParams, setSearchParams] = useSearchParams(); @@ -222,6 +235,28 @@ export default function SettingsPage() { const agentNames = agentOrder; const agentMap = new Map(agents.map((a) => [a.name.toLowerCase(), a])); + // Entering "Detected only" is handled by the App-level reconcile effect: it + // disables undetected agents and records them in the snapshot (so it also + // catches agents added by a later app update). Here we only restore that + // snapshot on the way back to "All agents" — re-enabling exactly those, never + // agents the user disabled by hand. + const handleVisibilityChange = (next: AgentVisibility) => { + if (next === agentVisibility) return; + if (next === "all") { + setEnabledBulk(autoDisabledAgents, true); + setAutoDisabledAgents([]); + } + setAgentVisibility(next); + toast.success( + t("agentPaths.visibilityToast", { + label: t( + AGENT_VISIBILITY_OPTIONS.find((o) => o.value === next)?.labelKey ?? + "agentPaths.visibilityAll", + ), + }), + ); + }; + const existingPaths = new Set(projects.map((p) => p.path)); const handleAddPath = async (path: string) => { @@ -309,18 +344,52 @@ export default function SettingsPage() {
{/* Agent Paths */}
-
-

- {t("agentPaths.section")} -

-

- {t("agentPaths.description")} -

+ {/* Header: title + description, with visibility toggle top-right */} +
+
+

+ {t("agentPaths.section")} +

+

+ {t("agentPaths.description")} +

+

+ {t("agentPaths.visibilityHint")} +

+
+
+ {AGENT_VISIBILITY_OPTIONS.map((opt, i) => ( + + ))} +
+
{agentNames.map((agent) => { const info = agentMap.get(agent); const isEnabled = info?.enabled ?? true; + // In "Detected only" we auto-disable undetected agents, so lock + // their toggle — switch to "All agents" to change them. Detected + // agents stay toggleable: enabling/disabling them is the user's + // call. + const locked = + agentVisibility === "detected" && !(info?.detected ?? false); return (