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' && (