From e8e8e1613e6ebd36967117c666cdf6979cb8c06c Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 10 Aug 2026 11:15:15 +0800 Subject: [PATCH 1/2] fix(acp-agents): hide remote servers without deleting --- .../config/components/AcpAgentsConfig.scss | 26 ++- .../components/AcpAgentsConfig.test.tsx | 134 ++++++++++++++- .../config/components/AcpAgentsConfig.tsx | 155 +++++++++++++++++- .../locales/en-US/settings/acp-agents.json | 9 +- .../locales/zh-CN/settings/acp-agents.json | 9 +- .../locales/zh-TW/settings/acp-agents.json | 9 +- 6 files changed, 330 insertions(+), 12 deletions(-) diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss index 3dcbc2d96a..0822e72688 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.scss @@ -253,6 +253,29 @@ gap: $size-gap-3; } + &__hidden-remote-list { + display: flex; + flex-direction: column; + margin-top: $size-gap-3; + overflow: hidden; + border: 1px solid var(--bf-appearance-token-border-subtle); + border-radius: $size-radius-base; + } + + &__hidden-remote-row { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: $size-gap-3; + align-items: center; + min-width: 0; + padding: $size-gap-3; + border-bottom: 1px solid var(--bf-appearance-token-border-subtle); + + &:last-child { + border-bottom: 0; + } + } + &__remote-server { display: flex; flex-direction: column; @@ -328,7 +351,8 @@ @media (max-width: 860px) { &__toolbar, &__registry-row, - &__remote-head { + &__remote-head, + &__hidden-remote-row { grid-template-columns: 1fr; } diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx index 31575d6cfe..64bb96d2e4 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.test.tsx @@ -41,6 +41,22 @@ vi.mock('@/component-library', () => ({ {children} ), + IconButton: ({ + children, + disabled, + isLoading, + onClick, + tooltip: _tooltip, + ...props + }: React.ButtonHTMLAttributes & { + children: React.ReactNode; + isLoading?: boolean; + tooltip?: React.ReactNode; + }) => ( + + ), Input: ({ value, onChange, @@ -84,13 +100,18 @@ vi.mock('./common', () => ({ children, title, description, + extra, }: { children: React.ReactNode; title: string; description?: string; + extra?: React.ReactNode; }) => (
-

{title}

+
+

{title}

+ {extra} +
{description ?

{description}

: null} {children}
@@ -141,6 +162,7 @@ describe('AcpAgentsConfig', () => { beforeEach(() => { (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + localStorage.clear(); loadJsonConfigMock.mockResolvedValue(JSON.stringify({ acpClients: { opencode: { @@ -233,6 +255,116 @@ describe('AcpAgentsConfig', () => { }); }); + it('hides a saved remote server without deleting its SSH connection', async () => { + listSavedConnectionsMock.mockResolvedValue([{ + id: 'huawei-server', + name: 'Huawei Server', + host: '119.8.182.138', + port: 22, + username: 'ssh-root', + authType: { type: 'Password' }, + }]); + + await act(async () => { + root.render(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const hideButton = container.querySelector( + 'button[aria-label="remote.hideConnection"]' + ); + expect(hideButton).not.toBeNull(); + + await act(async () => { + hideButton?.click(); + await Promise.resolve(); + }); + + expect(listSavedConnectionsMock).toHaveBeenCalledTimes(1); + expect(container.textContent).not.toContain('Huawei Server'); + expect(JSON.parse(localStorage.getItem('bitfun:settings:acp-agents:hidden-remote-connections:v1') || '[]')) + .toEqual(['huawei-server']); + expect(container.textContent).toContain('remote.showHiddenConnections'); + }); + + it('restores a hidden remote server from the hidden list', async () => { + localStorage.setItem( + 'bitfun:settings:acp-agents:hidden-remote-connections:v1', + JSON.stringify(['huawei-server']) + ); + listSavedConnectionsMock.mockResolvedValue([{ + id: 'huawei-server', + name: 'Huawei Server', + host: '119.8.182.138', + port: 22, + username: 'ssh-root', + authType: { type: 'Password' }, + }]); + + await act(async () => { + root.render(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + const showHiddenButton = Array.from(container.querySelectorAll('button')) + .find(button => button.textContent?.includes('remote.showHiddenConnections')); + expect(showHiddenButton).not.toBeUndefined(); + + await act(async () => { + showHiddenButton?.click(); + await Promise.resolve(); + }); + + const restoreButton = container.querySelector( + 'button[aria-label="remote.restoreConnection"]' + ); + expect(restoreButton).not.toBeNull(); + + await act(async () => { + restoreButton?.click(); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(localStorage.getItem('bitfun:settings:acp-agents:hidden-remote-connections:v1')) + .toBe('[]'); + expect(container.textContent).toContain('Huawei Server'); + }); + + it('does not probe hidden remote servers until they are restored', async () => { + localStorage.setItem( + 'bitfun:settings:acp-agents:hidden-remote-connections:v1', + JSON.stringify(['huawei-server']) + ); + listSavedConnectionsMock.mockResolvedValue([{ + id: 'huawei-server', + name: 'Huawei Server', + host: '119.8.182.138', + port: 22, + username: 'ssh-root', + authType: { type: 'Password' }, + }]); + + await act(async () => { + root.render(); + }); + await act(async () => { + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(probeClientRequirementsMock).not.toHaveBeenCalledWith({ + remoteConnectionId: 'huawei-server', + force: undefined, + }); + }); + it('configures a preset adapter when the CLI is ready but the ACP layer is missing', async () => { probeClientRequirementsMock.mockResolvedValue([ { diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx index be35d42ca4..6f291eed76 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.tsx @@ -4,6 +4,8 @@ import { Bot, CircleAlert, Download, + Eye, + EyeOff, ExternalLink, FileJson, LoaderCircle, @@ -14,7 +16,7 @@ import { Server, Terminal, } from 'lucide-react'; -import { Button, Input, Select, Textarea } from '@/component-library'; +import { Button, IconButton, Input, Select, Textarea } from '@/component-library'; import { ConfigPageContent, ConfigPageHeader, @@ -37,6 +39,31 @@ import { createLogger } from '@/shared/utils/logger'; import './AcpAgentsConfig.scss'; const log = createLogger('AcpAgentsConfig'); +const HIDDEN_REMOTE_CONNECTION_IDS_STORAGE_KEY = + 'bitfun:settings:acp-agents:hidden-remote-connections:v1'; + +function loadHiddenRemoteConnectionIds(): Set { + try { + const stored = localStorage.getItem(HIDDEN_REMOTE_CONNECTION_IDS_STORAGE_KEY); + if (!stored) return new Set(); + const parsed = JSON.parse(stored); + if (!Array.isArray(parsed)) return new Set(); + return new Set(parsed.filter((id): id is string => typeof id === 'string' && id.trim().length > 0)); + } catch { + return new Set(); + } +} + +function persistHiddenRemoteConnectionIds(connectionIds: Set): void { + try { + localStorage.setItem( + HIDDEN_REMOTE_CONNECTION_IDS_STORAGE_KEY, + JSON.stringify(Array.from(connectionIds).sort()) + ); + } catch { + // Keep the preference in memory when browser storage is unavailable. + } +} interface AcpClientConfig { name?: string; @@ -379,6 +406,8 @@ const AcpAgentsConfig: React.FC = () => { const [registryFilter, setRegistryFilter] = useState('all'); const [installingClientIds, setInstallingClientIds] = useState>(() => new Set()); const [installingRemoteClientIds, setInstallingRemoteClientIds] = useState>(() => new Set()); + const [hiddenRemoteConnectionIds, setHiddenRemoteConnectionIds] = useState(loadHiddenRemoteConnectionIds); + const [showHiddenRemoteConnections, setShowHiddenRemoteConnections] = useState(false); const requirementProbeRequestIdRef = useRef(0); const savingConfigRef = useRef(false); const loadedRemoteProbeIdsRef = useRef>(new Set()); @@ -393,6 +422,14 @@ const AcpAgentsConfig: React.FC = () => { return (left.name || left.id).localeCompare(right.name || right.id); }); }, [savedConnections]); + const visibleRemoteConnectionRows = useMemo( + () => remoteConnectionRows.filter(connection => !hiddenRemoteConnectionIds.has(connection.id)), + [hiddenRemoteConnectionIds, remoteConnectionRows] + ); + const hiddenRemoteConnectionRows = useMemo( + () => remoteConnectionRows.filter(connection => hiddenRemoteConnectionIds.has(connection.id)), + [hiddenRemoteConnectionIds, remoteConnectionRows] + ); const probesById = useMemo( () => new Map(requirementProbes.map(probe => [probe.id, probe])), [requirementProbes] @@ -579,6 +616,30 @@ const AcpAgentsConfig: React.FC = () => { } }, [notifyError, refreshRequirementProbes, t]); + const hideRemoteConnection = useCallback((connection: SavedConnection) => { + const connectionName = connection.name || connection.id; + setHiddenRemoteConnectionIds(prev => { + const next = new Set(prev).add(connection.id); + persistHiddenRemoteConnectionIds(next); + return next; + }); + notifySuccess(t('notifications.connectionHidden', { name: connectionName })); + }, [notifySuccess, t]); + + const restoreRemoteConnection = useCallback((connection: SavedConnection) => { + const connectionName = connection.name || connection.id; + if (hiddenRemoteConnectionRows.length <= 1) { + setShowHiddenRemoteConnections(false); + } + setHiddenRemoteConnectionIds(prev => { + const next = new Set(prev); + next.delete(connection.id); + persistHiddenRemoteConnectionIds(next); + return next; + }); + notifySuccess(t('notifications.connectionRestored', { name: connectionName })); + }, [hiddenRemoteConnectionRows.length, notifySuccess, t]); + useEffect(() => { void loadConfig(); }, [loadConfig]); @@ -598,10 +659,10 @@ const AcpAgentsConfig: React.FC = () => { useEffect(() => { if (loading) return; - for (const connection of remoteConnectionRows) { + for (const connection of visibleRemoteConnectionRows) { void refreshRemoteRequirementProbes(connection.id, { notifyOnError: false }); } - }, [loading, refreshRemoteRequirementProbes, remoteConnectionRows, remoteProbeRefreshNonce]); + }, [loading, refreshRemoteRequirementProbes, remoteProbeRefreshNonce, visibleRemoteConnectionRows]); const patchClientConfig = (clientId: string, patch: Partial) => { setConfig(prev => { @@ -1375,10 +1436,29 @@ const AcpAgentsConfig: React.FC = () => { )} - - {remoteConnectionRows.length === 0 ? ( + 0 ? ( + + ) : undefined} + > + {visibleRemoteConnectionRows.length === 0 ? (
- {t('remote.empty')} + {t(remoteConnectionRows.length === 0 ? 'remote.empty' : 'remote.emptyVisible')}
) : (
{ data-bf-component="acp-agents-config" data-bf-part="remoteList" > - {remoteConnectionRows.map(connection => { + {visibleRemoteConnectionRows.map(connection => { const hostLabel = [connection.username, connection.host] .filter(Boolean) .join('@'); @@ -1502,6 +1582,19 @@ const AcpAgentsConfig: React.FC = () => { {t('remote.refreshDetection')} + hideRemoteConnection(connection)} + > + +
{ })}
)} + {showHiddenRemoteConnections && hiddenRemoteConnectionRows.length > 0 && ( +
+ {hiddenRemoteConnectionRows.map(connection => { + const hostLabel = [connection.username, connection.host] + .filter(Boolean) + .join('@'); + return ( +
+
+ + + +
+ + {connection.name || connection.id} + +

+ {hostLabel || connection.id} +

+
+
+ restoreRemoteConnection(connection)} + > + + +
+ ); + })} +
+ )}
diff --git a/src/web-ui/src/locales/en-US/settings/acp-agents.json b/src/web-ui/src/locales/en-US/settings/acp-agents.json index ad4a4f580e..a7d0fe7cf9 100644 --- a/src/web-ui/src/locales/en-US/settings/acp-agents.json +++ b/src/web-ui/src/locales/en-US/settings/acp-agents.json @@ -69,8 +69,13 @@ "title": "Remote Servers", "description": "Saved SSH servers reuse the same ACP agent list and probe each remote host automatically.", "empty": "No saved SSH servers.", + "emptyVisible": "No visible SSH servers.", "noAgents": "Add an ACP agent before checking remote servers.", "refreshDetection": "Refresh detection", + "hideConnection": "Hide {{name}} from ACP Agents", + "restoreConnection": "Show {{name}} in ACP Agents", + "showHiddenConnections": "Hidden servers ({{count}})", + "hideHiddenConnections": "Hide hidden servers", "summary": "{{available}} / {{total}} available", "issueSummary": "{{count}} issue(s)" }, @@ -112,6 +117,8 @@ "downloadSuccess": "ACP agent CLI downloaded", "downloadFailed": "Failed to download ACP agent CLI", "predownloadSuccess": "ACP adapter downloaded", - "predownloadFailed": "Failed to download ACP adapter" + "predownloadFailed": "Failed to download ACP adapter", + "connectionHidden": "{{name}} is hidden from ACP Agents", + "connectionRestored": "{{name}} is shown in ACP Agents" } } diff --git a/src/web-ui/src/locales/zh-CN/settings/acp-agents.json b/src/web-ui/src/locales/zh-CN/settings/acp-agents.json index 690d86ab0d..9cc3406ac0 100644 --- a/src/web-ui/src/locales/zh-CN/settings/acp-agents.json +++ b/src/web-ui/src/locales/zh-CN/settings/acp-agents.json @@ -69,8 +69,13 @@ "title": "远程服务器", "description": "已保存的 SSH 服务器会复用同一份 ACP Agent 列表,并自动检测每台远端主机的状态。", "empty": "没有已保存的 SSH 服务器。", + "emptyVisible": "没有显示中的 SSH 服务器。", "noAgents": "请先添加 ACP Agent,再检测远程服务器。", "refreshDetection": "刷新检测", + "hideConnection": "在 ACP Agents 中隐藏「{{name}}」", + "restoreConnection": "在 ACP Agents 中显示「{{name}}」", + "showHiddenConnections": "已隐藏的服务器({{count}})", + "hideHiddenConnections": "收起已隐藏的服务器", "summary": "{{available}} / {{total}} 可用", "issueSummary": "{{count}} 个异常" }, @@ -112,6 +117,8 @@ "downloadSuccess": "ACP Agent CLI 已下载", "downloadFailed": "下载 ACP Agent CLI 失败", "predownloadSuccess": "ACP 适配器已下载", - "predownloadFailed": "下载 ACP 适配器失败" + "predownloadFailed": "下载 ACP 适配器失败", + "connectionHidden": "已在 ACP Agents 中隐藏「{{name}}」", + "connectionRestored": "已在 ACP Agents 中显示「{{name}}」" } } diff --git a/src/web-ui/src/locales/zh-TW/settings/acp-agents.json b/src/web-ui/src/locales/zh-TW/settings/acp-agents.json index 0a0db0a2ae..051c67e05e 100644 --- a/src/web-ui/src/locales/zh-TW/settings/acp-agents.json +++ b/src/web-ui/src/locales/zh-TW/settings/acp-agents.json @@ -69,8 +69,13 @@ "title": "遠端伺服器", "description": "已儲存的 SSH 伺服器會複用同一份 ACP Agent 列表,並自動檢測每台遠端主機的狀態。", "empty": "沒有已儲存的 SSH 伺服器。", + "emptyVisible": "沒有顯示中的 SSH 伺服器。", "noAgents": "請先新增 ACP Agent,再檢測遠端伺服器。", "refreshDetection": "重新整理檢測", + "hideConnection": "在 ACP Agents 中隱藏「{{name}}」", + "restoreConnection": "在 ACP Agents 中顯示「{{name}}」", + "showHiddenConnections": "已隱藏的伺服器({{count}})", + "hideHiddenConnections": "收起已隱藏的伺服器", "summary": "{{available}} / {{total}} 可用", "issueSummary": "{{count}} 個異常" }, @@ -112,6 +117,8 @@ "downloadSuccess": "ACP Agent CLI 已下載", "downloadFailed": "下載 ACP Agent CLI 失敗", "predownloadSuccess": "ACP 適配器已下載", - "predownloadFailed": "下載 ACP 適配器失敗" + "predownloadFailed": "下載 ACP 適配器失敗", + "connectionHidden": "已在 ACP Agents 中隱藏「{{name}}」", + "connectionRestored": "已在 ACP Agents 中顯示「{{name}}」" } } From cb2215f6dd78346e8c11b6addfcb98bc94801d63 Mon Sep 17 00:00:00 2001 From: wgqqqqq Date: Mon, 10 Aug 2026 11:44:35 +0800 Subject: [PATCH 2/2] fix(acp-agents): register hidden remote appearance parts --- .../config/components/AcpAgentsConfig.appearance.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.appearance.ts b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.appearance.ts index 31fe7d3e8b..9472d1fc14 100644 --- a/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.appearance.ts +++ b/src/web-ui/src/infrastructure/config/components/AcpAgentsConfig.appearance.ts @@ -17,6 +17,8 @@ export const acpAgentsConfigAppearanceDescriptor: AppearanceSurfaceDescriptor = { id: 'status' }, { id: 'confirmation' }, { id: 'remoteList' }, + { id: 'hiddenRemoteList' }, + { id: 'hiddenRemoteRow' }, { id: 'remoteServer' }, { id: 'remoteHeader' }, { id: 'remoteAgents' },