From 05838d2c50aaeb0b4b1a511a4b26cacc5830edc1 Mon Sep 17 00:00:00 2001 From: mesalogo Date: Tue, 13 Oct 2026 01:15:53 +0800 Subject: [PATCH] feat(hosts): move host management into the Hosts application Hosts becomes a built-in application opened from the Apps menu, with Hosts and Agent packages tabs. The remote capability switch keeps using remoteProjectsEnabled and moves to the application settings, so Main-side gating and saved values are unchanged. Saved navigation gains the new entry at the end, unpinned. Settings drops the SSH hosts category and the Remote projects tab. The host list collapses to one-line summaries with status badges, versions show as a table, and application settings switches align to the right edge. --- src/main/application-settings-store.test.ts | 24 +- .../src/AgentPackageInventoryPanel.test.tsx | 151 +++++ .../src/AgentPackageInventoryPanel.tsx | 443 ++++++++++++++ src/renderer/src/App.test.tsx | 23 +- src/renderer/src/App.tsx | 69 ++- src/renderer/src/ApplicationCenter.test.tsx | 58 +- src/renderer/src/ApplicationCenter.tsx | 25 +- src/renderer/src/ApplicationMenu.test.tsx | 12 +- src/renderer/src/ApplicationMenu.tsx | 4 +- .../src/PlatformFeaturesSettingsSection.tsx | 547 +----------------- src/renderer/src/RemoteHostsPage.test.tsx | 118 ++++ src/renderer/src/RemoteHostsPage.tsx | 131 +++++ src/renderer/src/SettingsPanel.test.tsx | 396 ++----------- src/renderer/src/SettingsPanel.tsx | 47 +- .../src/SshHostsSettingsSection.test.tsx | 40 +- src/renderer/src/SshHostsSettingsSection.tsx | 429 +++++++++----- src/renderer/src/application-center.css | 10 + src/renderer/src/i18n/locales/en-US/app.ts | 15 + .../i18n/locales/en-US/settingsSections.ts | 22 + .../src/i18n/locales/en-US/workspace.ts | 8 +- src/renderer/src/i18n/locales/zh-CN/app.ts | 15 + .../i18n/locales/zh-CN/settingsSections.ts | 22 + .../src/i18n/locales/zh-CN/workspace.ts | 8 +- src/renderer/src/remote-hosts.css | 260 +++++++++ src/renderer/src/settings-categories.test.ts | 12 +- src/renderer/src/settings-categories.ts | 18 +- src/shared/application-settings-contracts.ts | 12 +- src/shared/goodbuddy-config-contracts.test.ts | 6 +- 28 files changed, 1750 insertions(+), 1175 deletions(-) create mode 100644 src/renderer/src/AgentPackageInventoryPanel.test.tsx create mode 100644 src/renderer/src/AgentPackageInventoryPanel.tsx create mode 100644 src/renderer/src/RemoteHostsPage.test.tsx create mode 100644 src/renderer/src/RemoteHostsPage.tsx create mode 100644 src/renderer/src/remote-hosts.css diff --git a/src/main/application-settings-store.test.ts b/src/main/application-settings-store.test.ts index 87b48bfb..c4051a7e 100644 --- a/src/main/application-settings-store.test.ts +++ b/src/main/application-settings-store.test.ts @@ -282,7 +282,7 @@ describe('ApplicationSettingsStore', () => { const settings = await store.get() expect(settings.warnings).toBeUndefined() expect(settings.magicNotesEnabled).toBe(false) - expect(settings.applicationNavigation).toEqual({ order: [...expected, 'device-sharing'], pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false } }) + expect(settings.applicationNavigation).toEqual({ order: [...expected, 'device-sharing', 'remote-hosts'], pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': false } }) expect(applicationSettingsSchema.parse(settings)).toEqual(settings) await store.update({ checkUpdatesOnStartup: false }) expect(JSON.parse(await readFile(filePath, 'utf8')).applicationNavigation).toEqual(settings.applicationNavigation) @@ -292,8 +292,8 @@ describe('ApplicationSettingsStore', () => { it('validates full-order writes, persists and publishes them, and rejects partial writes without mutation', async () => { const { store, filePath } = await createStore() const applicationNavigation = { - order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'], - pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false }, + order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'remote-hosts', 'device-sharing'], + pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': true }, } const changed = vi.fn() store.onChanged(changed) @@ -337,11 +337,27 @@ describe('ApplicationSettingsStore', () => { pinned: { 'magic-notes': false, 'local-inference': pinned } } await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, applicationNavigation, version: 11, lastSeenReleaseNotesVersion: null }), 'utf8') - const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] } + const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true, 'remote-hosts': false }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'] } expect((await store.get()).applicationNavigation).toEqual(normalized) await store.update({ checkUpdatesOnStartup: false }) expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(normalized) }) + it.each([true, false])('adds Hosts after a saved full order and keeps remote projects %s', async (remoteProjectsEnabled) => { + const { filePath, store } = await createStore() + const applicationNavigation = { + order: ['device-sharing', 'local-inference', 'heartbeat', 'magic-notes', 'knowledge'], + pinned: { 'magic-notes': false, heartbeat: false, 'local-inference': true } + } + await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, remoteProjectsEnabled, applicationNavigation, version: 12, lastSeenReleaseNotesVersion: null }), 'utf8') + const expected = { order: [...applicationNavigation.order, 'remote-hosts'], pinned: { ...applicationNavigation.pinned, 'remote-hosts': false } } + const migrated = await store.get() + expect(migrated.warnings).toBeUndefined() + expect(migrated.remoteProjectsEnabled).toBe(remoteProjectsEnabled) + expect(migrated.applicationNavigation).toEqual(expected) + expect(applicationSettingsSchema.parse(migrated)).toEqual(migrated) + await store.update({ checkUpdatesOnStartup: false }) + expect(await createApplicationSettingsStore(filePath).get()).toMatchObject({ remoteProjectsEnabled, applicationNavigation: expected }) + }) it('migrates released settings without replacing explicit false values or comment choices', async () => { const { filePath, store } = await createStore() const legacy: Record = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' } diff --git a/src/renderer/src/AgentPackageInventoryPanel.test.tsx b/src/renderer/src/AgentPackageInventoryPanel.test.tsx new file mode 100644 index 00000000..5cf896e0 --- /dev/null +++ b/src/renderer/src/AgentPackageInventoryPanel.test.tsx @@ -0,0 +1,151 @@ +import { + act, + cleanup, + fireEvent, + render, + screen, + waitFor, + within +} from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import type { AgentPackageInventory } from '../../shared/agent-package-contracts' +import { AgentPackageInventoryPanel } from './AgentPackageInventoryPanel' +import { changeUiLocale } from './i18n' + +const agentPackageInventory: AgentPackageInventory = { + checkedAt: '2026-08-24T00:00:00.000Z', + catalog: { + state: 'available', + checkedAt: '2026-08-24T00:00:00.000Z', + error: null + }, + entries: [ + { + platform: 'linux', + architecture: 'x64', + state: 'verified', + version: '0.11.2', + latestVersion: '0.11.4', + updateAvailable: true, + agentProtocol: { major: 2, minor: 0 }, + remoteRuntimeVersion: '1.18.29' + }, + { + platform: 'linux', + architecture: 'arm64', + state: 'not-downloaded', + version: null, + latestVersion: '0.11.4', + updateAvailable: false, + agentProtocol: null, + remoteRuntimeVersion: null + } + ] +} +const getAgentPackageInventory = vi.fn<(refresh?: boolean) => Promise>() +const downloadAgentPackage = vi.fn<(architecture: string, platform: string) => Promise>() +const importAgentPackage = vi.fn<() => Promise>() +const exportAgentPackage = vi.fn<(architecture: string, platform: string) => Promise>() +const onAgentPackageProgress = vi.fn(() => () => {}) + +describe('AgentPackageInventoryPanel', () => { + beforeEach(async () => { + await changeUiLocale('zh-CN') + vi.clearAllMocks() + getAgentPackageInventory.mockResolvedValue(agentPackageInventory) + downloadAgentPackage.mockResolvedValue(agentPackageInventory) + importAgentPackage.mockResolvedValue(agentPackageInventory) + exportAgentPackage.mockResolvedValue(undefined) + Object.defineProperty(window, 'goodbuddy', { + configurable: true, + value: { + sshHosts: { + getAgentPackageInventory, + downloadAgentPackage, + importAgentPackage, + exportAgentPackage, + onAgentPackageProgress + } + } + }) + }) + afterEach(() => cleanup()) + + it('waits until shown, then lists, refreshes, downloads, exports, and imports packages', async () => { + const onNotify = vi.fn() + const onBusyChange = vi.fn() + const { rerender } = render( + + ) + expect(getAgentPackageInventory).not.toHaveBeenCalled() + rerender() + + expect(await screen.findByText('GoodBuddy Agent 包')).toBeInTheDocument() + expect(await screen.findByText('当前 1 / 2 个平台与架构目标可用。')).toBeInTheDocument() + expect(screen.getByText('有更新')).toBeInTheDocument() + expect(screen.getByText('未下载')).toBeInTheDocument() + expect(getAgentPackageInventory).toHaveBeenCalledExactlyOnceWith(false) + + fireEvent.click(screen.getByRole('button', { name: '刷新包清单' })) + await waitFor(() => expect(getAgentPackageInventory).toHaveBeenCalledTimes(2)) + expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, true) + + const x64Card = screen.getByText('Linux x64').closest('[role="listitem"]') as HTMLElement + const arm64Card = screen.getByText('Linux arm64').closest('[role="listitem"]') as HTMLElement + downloadAgentPackage.mockResolvedValueOnce({ + ...agentPackageInventory, + entries: agentPackageInventory.entries.map((entry) => + entry.architecture === 'x64' ? { ...entry, version: '0.11.4', updateAvailable: false } : entry + ) + }) + fireEvent.click(within(x64Card).getByRole('button', { name: '更新到 0.11.4' })) + await waitFor(() => expect(downloadAgentPackage).toHaveBeenCalledWith('x64', 'linux')) + expect(await within(x64Card).findByText('已是最新')).toBeInTheDocument() + expect(onBusyChange).toHaveBeenCalledWith(true) + await waitFor(() => expect(onBusyChange).toHaveBeenLastCalledWith(false)) + + fireEvent.click(within(x64Card).getByRole('button', { name: '导出离线包' })) + await waitFor(() => expect(exportAgentPackage).toHaveBeenCalledWith('x64', 'linux')) + await waitFor(() => expect(within(arm64Card).getByRole('button', { name: '下载 0.11.4' })).toBeEnabled()) + fireEvent.click(within(arm64Card).getByRole('button', { name: '下载 0.11.4' })) + await waitFor(() => expect(downloadAgentPackage).toHaveBeenCalledWith('arm64', 'linux')) + await waitFor(() => expect(screen.getByRole('button', { name: '导入离线包' })).toBeEnabled()) + fireEvent.click(screen.getByRole('button', { name: '导入离线包' })) + await waitFor(() => expect(importAgentPackage).toHaveBeenCalledOnce()) + await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ dedupeKey: 'agent-package-imported' }))) + }) + + it('shows loading and recovers from a refresh error', async () => { + let resolveInventory: ((inventory: AgentPackageInventory) => void) | undefined + getAgentPackageInventory.mockReturnValueOnce( + new Promise((resolve) => { resolveInventory = resolve }) + ) + render() + expect(await screen.findByText('正在校验本地 Agent 包并检查在线版本…')).toBeInTheDocument() + await act(async () => { + resolveInventory?.(agentPackageInventory) + await Promise.resolve() + }) + expect(await screen.findByText('当前 1 / 2 个平台与架构目标可用。')).toBeInTheDocument() + + getAgentPackageInventory.mockRejectedValueOnce(new Error('inventory unavailable')) + fireEvent.click(screen.getByRole('button', { name: '刷新包清单' })) + await screen.findByText('inventory unavailable') + fireEvent.click(screen.getByRole('button', { name: '刷新包清单' })) + await waitFor(() => expect(screen.queryByText('inventory unavailable')).not.toBeInTheDocument()) + expect(getAgentPackageInventory).toHaveBeenCalledTimes(3) + }) + + it('keeps local Agent packages usable when the online catalog is unavailable', async () => { + getAgentPackageInventory.mockResolvedValueOnce({ + ...agentPackageInventory, + catalog: { state: 'unavailable', checkedAt: '2026-08-24T00:00:00.000Z', error: 'network unavailable' }, + entries: agentPackageInventory.entries.map((entry) => ({ ...entry, latestVersion: null, updateAvailable: false })) + }) + render() + expect(await screen.findByText(/network unavailable/u)).toBeInTheDocument() + const x64Card = screen.getByText('Linux x64').closest('[role="listitem"]') as HTMLElement + expect(within(x64Card).getByRole('button', { name: '检查并更新' })).toBeInTheDocument() + expect(within(x64Card).getByRole('button', { name: '导出离线包' })).toBeInTheDocument() + }) +}) diff --git a/src/renderer/src/AgentPackageInventoryPanel.tsx b/src/renderer/src/AgentPackageInventoryPanel.tsx new file mode 100644 index 00000000..2b8f4f33 --- /dev/null +++ b/src/renderer/src/AgentPackageInventoryPanel.tsx @@ -0,0 +1,443 @@ +import { Cpu, Download, RefreshCw, Upload } from 'lucide-react' +import { useCallback, useEffect, useRef, useState } from 'react' +import { useTranslation } from 'react-i18next' +import type { + AgentPackageDownloadProgress, + AgentPackageInventory +} from '../../shared/agent-package-contracts' +import type { AgentArchitecture } from '../../shared/agent-installation-contracts' +import type { AppNotificationInput } from './notifications' +import { displayErrorMessage } from './error-message' + +// Local Agent package inventory used to install and update remote hosts. +// Package state lives in Main and is independent of host records. +export function AgentPackageInventoryPanel({ + active = true, + onBusyChange, + onNotify +}: { + active?: boolean + onBusyChange?: (busy: boolean) => void + onNotify?: (notification: AppNotificationInput) => void +}): React.JSX.Element { + const { t } = useTranslation('settingsSections') + const [agentInventory, setAgentInventory] = + useState() + const [agentInventoryLoading, setAgentInventoryLoading] = + useState(false) + const [agentInventoryError, setAgentInventoryError] = + useState() + const [agentPackageBusy, setAgentPackageBusy] = + useState() + const [agentPackageProgress, setAgentPackageProgress] = + useState() + const agentInventoryRequested = useRef(false) + + useEffect(() => { + onBusyChange?.(agentPackageBusy !== undefined) + }, [agentPackageBusy, onBusyChange]) + useEffect(() => () => onBusyChange?.(false), [onBusyChange]) + + const loadAgentInventory = useCallback(async ( + refresh = false + ): Promise => { + const getInventory = + window.goodbuddy.sshHosts?.getAgentPackageInventory + if (!getInventory) { + setAgentInventoryError( + t( + 'platformFeatures.remoteProjects.agentInventory.errors.unavailable' + ) + ) + return + } + setAgentInventoryLoading(true) + setAgentInventoryError(undefined) + try { + setAgentInventory(await getInventory(refresh)) + } catch (reason) { + setAgentInventoryError( + displayErrorMessage( + reason, + t( + 'platformFeatures.remoteProjects.agentInventory.errors.readFailed' + ) + ) + ) + } finally { + setAgentInventoryLoading(false) + } + }, [t]) + + // Reading the inventory checks the online catalog, so it waits until shown. + useEffect(() => { + if (!active || agentInventoryRequested.current) { + return + } + agentInventoryRequested.current = true + void loadAgentInventory() + }, [active, loadAgentInventory]) + + useEffect(() => { + const subscribe = + window.goodbuddy.sshHosts?.onAgentPackageProgress + return subscribe?.((progress) => + setAgentPackageProgress(progress) + ) + }, []) + + const downloadAgentPackage = async ( + architecture: AgentArchitecture, + platform: 'linux' | 'darwin' + ): Promise => { + const download = + window.goodbuddy.sshHosts?.downloadAgentPackage + if (!download) { + return + } + setAgentPackageBusy(`${platform}-${architecture}`) + setAgentPackageProgress(undefined) + setAgentInventoryError(undefined) + try { + setAgentInventory(await download(architecture, platform)) + onNotify?.({ + dedupeKey: `agent-package-downloaded:${platform}-${architecture}`, + message: t( + 'platformFeatures.remoteProjects.agentInventory.notifications.downloaded', + { architecture, platform: platform === 'darwin' ? 'macOS' : 'Linux' } + ), + tone: 'success' + }) + } catch (reason) { + setAgentInventoryError( + displayErrorMessage( + reason, + t( + 'platformFeatures.remoteProjects.agentInventory.errors.downloadFailed' + ) + ) + ) + } finally { + setAgentPackageBusy(undefined) + setAgentPackageProgress(undefined) + } + } + + const importAgentPackage = async (): Promise => { + const importPackage = + window.goodbuddy.sshHosts?.importAgentPackage + if (!importPackage) { + return + } + setAgentPackageBusy('import') + setAgentInventoryError(undefined) + try { + const inventory = await importPackage() + if (inventory) { + setAgentInventory(inventory) + onNotify?.({ + dedupeKey: 'agent-package-imported', + message: t( + 'platformFeatures.remoteProjects.agentInventory.notifications.imported' + ), + tone: 'success' + }) + } + } catch (reason) { + setAgentInventoryError( + displayErrorMessage( + reason, + t( + 'platformFeatures.remoteProjects.agentInventory.errors.importFailed' + ) + ) + ) + } finally { + setAgentPackageBusy(undefined) + } + } + + const exportAgentPackage = async ( + architecture: AgentArchitecture, + platform: 'linux' | 'darwin' + ): Promise => { + const exportPackage = + window.goodbuddy.sshHosts?.exportAgentPackage + if (!exportPackage) { + return + } + setAgentPackageBusy(`${platform}-${architecture}`) + setAgentInventoryError(undefined) + try { + await exportPackage(architecture, platform) + onNotify?.({ + dedupeKey: `agent-package-exported:${platform}-${architecture}`, + message: t( + 'platformFeatures.remoteProjects.agentInventory.notifications.exported', + { architecture, platform: platform === 'darwin' ? 'macOS' : 'Linux' } + ), + tone: 'success' + }) + } catch (reason) { + setAgentInventoryError( + displayErrorMessage( + reason, + t( + 'platformFeatures.remoteProjects.agentInventory.errors.exportFailed' + ) + ) + ) + } finally { + setAgentPackageBusy(undefined) + } + } + + return ( +
+
+
+ + {t( + 'platformFeatures.remoteProjects.agentInventory.title' + )} + + + {t( + 'platformFeatures.remoteProjects.agentInventory.description' + )} + +
+
+ + +
+
+ {agentInventory && ( +

+ {t( + 'platformFeatures.remoteProjects.agentInventory.summary', + { + available: agentInventory.entries.filter( + (entry) => entry.state === 'verified' + ).length, + total: agentInventory.entries.length + } + )} +

+ )} + {agentInventoryLoading && !agentInventory ? ( +

+ {t( + 'platformFeatures.remoteProjects.agentInventory.loading' + )} +

+ ) : agentInventoryError ? ( +

+ {agentInventoryError} +

+ ) : agentInventory ? ( +
+ {agentInventory.entries.map((entry) => ( +
+
+
+
+
+
+ {t( + 'platformFeatures.remoteProjects.agentInventory.fields.agentVersion' + )} +
+
{entry.version ?? '—'}
+
+
+
+ {t( + 'platformFeatures.remoteProjects.agentInventory.fields.latestVersion' + )} +
+
{entry.latestVersion ?? '—'}
+
+
+
+ {t( + 'platformFeatures.remoteProjects.agentInventory.fields.architecture' + )} +
+
{entry.architecture}
+
+
+
+ {t( + 'platformFeatures.remoteProjects.agentInventory.fields.runtimeVersion' + )} +
+
{entry.remoteRuntimeVersion ?? '—'}
+
+
+
+ {t( + 'platformFeatures.remoteProjects.agentInventory.fields.protocol' + )} +
+
+ {entry.agentProtocol + ? `${entry.agentProtocol.major}.${entry.agentProtocol.minor}` + : '—'} +
+
+
+ {agentPackageProgress?.architecture === + entry.architecture && + agentPackageProgress.platform === entry.platform && + agentPackageBusy === `${entry.platform}-${entry.architecture}` && ( +

+ {t( + `platformFeatures.remoteProjects.agentInventory.progress.${agentPackageProgress.phase}` + )} +

+ )} +
+ {( + entry.updateAvailable || + ( + entry.state !== 'verified' && + entry.latestVersion !== null + ) || + agentInventory.catalog.state !== 'available' + ) && ( + + )} + {entry.state === 'verified' && ( + + )} +
+
+ ))} +
+ ) : null} + {agentInventory?.catalog.state === 'available' && ( +

+ {t( + 'platformFeatures.remoteProjects.agentInventory.catalog.available' + )} +

+ )} + {agentInventory?.catalog.state === 'unavailable' && ( +

+ {t( + 'platformFeatures.remoteProjects.agentInventory.catalog.unavailable', + { error: agentInventory.catalog.error } + )} +

+ )} +
+ ) +} diff --git a/src/renderer/src/App.test.tsx b/src/renderer/src/App.test.tsx index 8398d582..84e6f839 100644 --- a/src/renderer/src/App.test.tsx +++ b/src/renderer/src/App.test.tsx @@ -9681,15 +9681,14 @@ describe("App", () => { ).toHaveTextContent(remoteProject.name), ); - fireEvent.click(screen.getByRole('button', { name: '设置' })); - fireEvent.click(await screen.findByRole("tab", { name: "平台功能" })); + // The switch now lives in the Hosts application settings. + fireEvent.click(screen.getByRole("button", { name: "应用" })); + fireEvent.click(await screen.findByRole("menuitem", { name: "管理应用" })); fireEvent.click( - await screen.findByRole("tab", { - name: "远程项目", - }), + await screen.findByRole("button", { name: "主机管理 应用设置" }), ); const remoteProjectsSwitch = await screen.findByRole("switch", { - name: "远程项目", + name: "启用远程能力", }); expect(remoteProjectsSwitch).toBeChecked(); fireEvent.click(remoteProjectsSwitch); @@ -15024,7 +15023,7 @@ describe("App", () => { ); await screen.findByRole("heading", { name: "设置中心" }); fireEvent.click(screen.getByRole("tab", { name: "平台功能" })); - await screen.findByRole("tab", { name: "通用设置" }); + await screen.findByLabelText("默认工作区目录"); expect(screen.queryByRole("tab", { name: "魔法笔记" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "应用设置" })).not.toBeInTheDocument(); expect(api.updates.updateSettings).not.toHaveBeenCalled(); @@ -15073,24 +15072,24 @@ describe("App", () => { await waitFor(() => expect(move).toBeEnabled()) fireEvent.click(move) await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([ - '监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享(技术预览)', + '监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享(技术预览)', '主机管理', ])) await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled()) const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article') fireEvent.dragStart(cards[3]!) fireEvent.drop(cards[1]!) await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([ - '监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)', + '监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)', '主机管理', ])) - expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes', 'device-sharing']) + expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes', 'device-sharing', 'remote-hosts']) fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' })) expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([ '对话', '监督者', '知识库', '魔法笔记', '运行记录', ]) fireEvent.click(screen.getByRole('button', { name: '应用' })) - expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '管理应用']) + expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '主机管理', '管理应用']) fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' })) - expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)']) + expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)', '主机管理']) }) it('leaves device sharing on an external settings event and keeps it closed when re-enabled', async () => { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index a94e2e97..ce9da004 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -14,6 +14,7 @@ import { ApplicationCenter, applicationDefinitions, isApplicationEnabled, + isApplicationOpenable, } from "./ApplicationCenter"; import { defaultApplicationNavigation, @@ -287,6 +288,10 @@ const activityPanelRoute = createPreloadableComponent( () => import("./ActivityPanel"), (module) => module.ActivityPanel, ); +const remoteHostsPageRoute = createPreloadableComponent( + () => import("./RemoteHostsPage"), + (module) => module.RemoteHostsPage, +); const idleRouteModuleLoaders = [heartbeatCenterRoute.preload] as const; const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component; @@ -294,6 +299,7 @@ const HeartbeatCenter = heartbeatCenterRoute.Component; const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component; const SettingsPanel = settingsPanelRoute.Component; const ActivityPanel = activityPanelRoute.Component; +const RemoteHostsPage = remoteHostsPageRoute.Component; const conversationPersistenceIntervalMs = 500; // How often streaming deltas are absorbed into the conversation store; rows render them live. @@ -507,14 +513,14 @@ export function AppNotificationViewport({ } type WorkspaceView = - "chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "device-sharing" | "activity" | "settings"; + "chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "device-sharing" | "remote-hosts" | "activity" | "settings"; // Tracks which kept-alive routes currently report unsaved edits. Reads happen // only in event handlers and the sweep timer, so this is not React state. function createUnsavedWorkspaceTracker(): { pinnedViews: () => Set; reporters: Record< - "knowledge" | "heartbeat" | "magic-notes" | "activity", + "knowledge" | "heartbeat" | "magic-notes" | "remote-hosts" | "activity", ReportWorkspaceUnsavedChanges >; } { @@ -535,6 +541,7 @@ function createUnsavedWorkspaceTracker(): { knowledge: reporterFor("knowledge"), heartbeat: reporterFor("heartbeat"), "magic-notes": reporterFor("magic-notes"), + "remote-hosts": reporterFor("remote-hosts"), activity: reporterFor("activity"), }, }; @@ -547,6 +554,7 @@ const intentRoutePreloaders: Partial< knowledge: knowledgeWorkspaceRoute.preload, activity: activityPanelRoute.preload, settings: settingsPanelRoute.preload, + "remote-hosts": remoteHostsPageRoute.preload, }; function preloadWorkspaceRouteOnIntent(view: WorkspaceView): void { @@ -1515,6 +1523,9 @@ function App(): React.JSX.Element { setActiveIdState(next); }, [activeConversationViewIds, conversationStore]); const [remoteProjectsEnabled, setRemoteProjectsEnabled] = useState(false); + const remoteProjectsEnabledRef = useRef(false); + // Assigned after the handler is defined below; reads latest projects at call time. + const remoteProjectsEnabledChangeRef = useRef<(enabled: boolean) => void>(setRemoteProjectsEnabled); const [ conversationHtmlRenderingEnabled, setConversationHtmlRenderingEnabled, @@ -1527,7 +1538,7 @@ function App(): React.JSX.Element { const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = useState(0); const applicationNavigation = applicationSettings?.applicationNavigation ?? defaultApplicationNavigation; const visibleApplications = useMemo(() => applicationNavigation.order.filter(id => - id === 'knowledge' || (Boolean((applicationNavigation.pinned as Record)[id]) && (id === 'magic-notes' ? magicNotesEnabled : isApplicationEnabled(applicationSettings, id))) + id === 'knowledge' || (Boolean((applicationNavigation.pinned as Record)[id]) && (id === 'magic-notes' ? magicNotesEnabled : isApplicationOpenable(applicationSettings, id))) ), [applicationNavigation, applicationSettings, magicNotesEnabled]); const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => { setApplicationSettings(settings); @@ -1549,7 +1560,11 @@ function App(): React.JSX.Element { } setMagicNotesShowIncompleteTodoCount(settings.magicNotesShowIncompleteTodoCount); setConversationHtmlRenderingEnabled(settings.conversationHtmlRenderingEnabled !== false); - setRemoteProjectsEnabled(settings.remoteProjectsEnabled); + // Whichever surface changed the switch, leave an active SSH project when it turns off. + if (remoteProjectsEnabledRef.current !== settings.remoteProjectsEnabled) { + remoteProjectsEnabledRef.current = settings.remoteProjectsEnabled; + remoteProjectsEnabledChangeRef.current(settings.remoteProjectsEnabled); + } }, [guardNoteDraft, setNoteDraft, setView]); const reloadApplicationSettings = useCallback(async (): Promise => { const revision = ++applicationSettingsRevisionRef.current; @@ -4310,9 +4325,15 @@ function App(): React.JSX.Element { } const localProject = projects.find(isOrdinaryLocalProject); if (localProject) { + // Keep the Hosts page open when the switch is turned off there. + const currentView = viewRef.current; commitProjectSelection(localProject); + if (currentView === "remote-hosts") commitView("remote-hosts"); } }; + useLayoutEffect(() => { + remoteProjectsEnabledChangeRef.current = handleRemoteProjectsEnabledChange; + }); const archiveProject = async (projectId: string): Promise => { await window.goodbuddy.projects.setArchived(projectId, true); @@ -6535,13 +6556,14 @@ function App(): React.JSX.Element { onExpertsChanged: (experts: AssistantExpert[]) => { setAssistantExperts(experts); }, - onRemoteProjectsEnabledChange: handleRemoteProjectsEnabledChange, - onProjectsDeleted: removeProjectsFromUi, onSaved: (settings: RuntimeSettings) => { setRuntimeSettings(settings); }, onUpdateProject: updateProject, }); + const remoteHostsActions = useStableHandlers({ + onProjectsDeleted: removeProjectsFromUi, + }); const rightSidebarActions = useStableHandlers({ onBeforeCloseNotes: async () => { if (!await guardNoteDraft()) return false; @@ -7339,6 +7361,38 @@ function App(): React.JSX.Element { )} {view === 'device-sharing' && isApplicationEnabled(applicationSettings, 'device-sharing') && } + {(view === "remote-hosts" || + cachedWorkspaceViewKeys.has("remote-hosts")) && ( + + + } + > + } + > + + + + + )} {localInferenceOpen && ( setApplicationCenterOpen(false)} onOpen={(id) => { - if (!isApplicationEnabled(applicationSettings, id)) return; + if (!isApplicationOpenable(applicationSettings, id)) return; setApplicationCenterOpen(false); setView(id); if (window.innerWidth < 900) setSidebarOpen(false); @@ -7410,7 +7464,6 @@ function App(): React.JSX.Element { initialCategory={settingsInitialCategory} initialChannel={settingsInitialChannel} magicNotesEnabled={magicNotesEnabled} - remoteProjectsEnabled={remoteProjectsEnabled} onConversationHtmlRenderingEnabledChange={ setConversationHtmlRenderingEnabled } diff --git a/src/renderer/src/ApplicationCenter.test.tsx b/src/renderer/src/ApplicationCenter.test.tsx index d63bf949..26f54fa4 100644 --- a/src/renderer/src/ApplicationCenter.test.tsx +++ b/src/renderer/src/ApplicationCenter.test.tsx @@ -17,6 +17,7 @@ import { ApplicationCenter, ApplicationSettingsView, isApplicationEnabled, + isApplicationOpenable, } from './ApplicationCenter' const settings = applicationSettingsSchema.parse({ @@ -39,7 +40,7 @@ const pinnedSettings = { ...settings, applicationNavigation: { ...defaultApplicationNavigation, - pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': true }, + pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': true, 'remote-hosts': false }, }, } afterEach(async () => { @@ -73,6 +74,35 @@ describe('Application Center', () => { expect(handlers.onOpen).toHaveBeenCalledWith('device-sharing') }) + it('opens Hosts while remote capability is off and switches it through the shared remote projects setting', () => { + const handlers = props() + const { rerender } = render() + expect(isApplicationEnabled(settings, 'remote-hosts')).toBe(false) + expect(isApplicationOpenable(settings, 'remote-hosts')).toBe(true) + expect(isApplicationOpenable(undefined, 'remote-hosts')).toBe(false) + const card = screen.getByText('主机管理', { exact: true }).closest('article')! + expect(within(card).getByText('可选 · 已关闭')).toBeVisible() + fireEvent.click(within(card).getByRole('button', { name: '打开' })) + expect(handlers.onOpen).toHaveBeenCalledWith('remote-hosts') + fireEvent.click(within(card).getByRole('button', { name: '主机管理 应用设置' })) + const enable = screen.getByRole('switch', { name: '启用远程能力' }) + expect(enable).not.toBeChecked() + expect(screen.getByText(/主机、凭据、远程项目和 Agent 离线包都会保留/)).toBeVisible() + fireEvent.click(enable) + expect(handlers.onUpdate).toHaveBeenLastCalledWith({ remoteProjectsEnabled: true }) + rerender() + expect(enable).toBeChecked() + const pin = screen.getByRole('switch', { name: '常驻左侧菜单' }) + expect(pin).not.toBeChecked() + fireEvent.click(pin) + expect(handlers.onUpdate).toHaveBeenLastCalledWith({ + applicationNavigation: { + ...defaultApplicationNavigation, + pinned: { ...defaultApplicationNavigation.pinned, 'remote-hosts': true }, + }, + }) + }) + it.each(['zh-CN', 'en-US'] as const)('keeps disable consequences visible and pin help separate in %s', async (locale) => { await changeUiLocale(locale) const onUpdate = vi.fn(async () => true) @@ -127,7 +157,7 @@ describe('Application Center', () => { fireEvent.drop(card) expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: { ...defaultApplicationNavigation, - order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing'], + order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing', 'remote-hosts'], } }) }) @@ -239,7 +269,7 @@ describe('Application Center', () => { fireEvent.dragStart(notes) fireEvent.drop(inference) expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: { - ...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'], + ...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'], } }) fireEvent.click(within(inference).getByRole('button', { name: '打开' })) expect(handlers.onOpen).not.toHaveBeenCalled() @@ -263,9 +293,9 @@ describe('Application Center', () => { const handlers = props() const { rerender } = render() const displayedOrder = () => screen.getAllByRole('article').map((row) => row.querySelector('strong')?.textContent) - expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)']) + expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)', '主机管理']) expect(screen.getByRole('button', { name: '上移 知识库' })).toBeDisabled() - expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled() + expect(screen.getByRole('button', { name: '下移 主机管理' })).toBeDisabled() const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' }) expect(moveUp.closest('article')).not.toBeNull() expect(moveUp).toHaveAttribute('title', '上移 本机推理监控') @@ -273,18 +303,18 @@ describe('Application Center', () => { expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: { ...pinnedSettings.applicationNavigation, - order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'], + order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'], }, }) - expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled() + expect(screen.getByRole('button', { name: '下移 主机管理' })).toBeDisabled() expect(await screen.findByRole('status')).not.toBeEmptyDOMElement() - expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)']) + expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)', '主机管理']) rerender() - expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享(技术预览)']) - expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled() + expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享(技术预览)', '主机管理']) + expect(screen.getByRole('button', { name: '下移 主机管理' })).toBeDisabled() fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' })) expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation }) fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) @@ -306,7 +336,7 @@ describe('Application Center', () => { expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: { ...pinnedSettings.applicationNavigation, - order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'], + order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'], }, }) }) @@ -352,7 +382,7 @@ describe('Application Center', () => { expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: { ...pinnedSettings.applicationNavigation, - order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'], + order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'], }, }) }) @@ -367,7 +397,7 @@ describe('Application Center', () => { }) expect(screen.getByText('未找到相关应用')).toBeInTheDocument() fireEvent.click(screen.getByRole('button', { name: '清除搜索' })) - expect(screen.getAllByRole('article')).toHaveLength(5) + expect(screen.getAllByRole('article')).toHaveLength(6) }) it('edits all notes preferences through the shared settings form', () => { diff --git a/src/renderer/src/ApplicationCenter.tsx b/src/renderer/src/ApplicationCenter.tsx index f2519873..1e36176c 100644 --- a/src/renderer/src/ApplicationCenter.tsx +++ b/src/renderer/src/ApplicationCenter.tsx @@ -14,6 +14,7 @@ import { Network, HeartPulse, Library, + Server, Settings, Sparkles, X, @@ -52,6 +53,12 @@ export const applicationDefinitions = { title: 'applications.localInference', enabled: 'localInferenceEnabled', }, + // Shares the remote projects switch: the backend gates hosts on it. + 'remote-hosts': { + icon: Server, + title: 'applications.remoteHosts', + enabled: 'remoteProjectsEnabled', + }, } as const export function isApplicationEnabled( @@ -60,6 +67,7 @@ export function isApplicationEnabled( ): boolean { if (id === 'knowledge') return true if (id === 'device-sharing') return settings?.deviceSharingEnabled === true + if (id === 'remote-hosts') return settings?.remoteProjectsEnabled === true if (id === 'heartbeat') return settings?.heartbeatEnabled === true return ( settings !== undefined && @@ -67,6 +75,17 @@ export function isApplicationEnabled( ) } +// Hosts stays openable while remote capability is off: Agent packages can be +// prepared ahead of time, and the page offers the switch itself. +export function isApplicationOpenable( + settings: ApplicationSettings | undefined, + id: BuiltInApplicationId, +): boolean { + return id === 'remote-hosts' + ? settings !== undefined + : isApplicationEnabled(settings, id) +} + export function ApplicationAvailability({ enabled, children, @@ -120,7 +139,7 @@ export function ApplicationSettingsView({ }) } /> - {t('applications.enable')} + {t(id === 'remote-hosts' ? 'applications.remoteHostsEnable' : 'applications.enable')} {id !== 'device-sharing' &&
{t('applications.pinHelp')}
} -

{t(id === 'device-sharing' ? 'applications.deviceSharingHelp' : 'applications.disableHelp')}

+

{t(id === 'device-sharing' ? 'applications.deviceSharingHelp' : id === 'remote-hosts' ? 'applications.remoteHostsHelp' : 'applications.disableHelp')}

{id === 'magic-notes' && (