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.
This commit is contained in:
mesalogo
2026-10-13 01:15:53 +08:00
parent 89bf5840aa
commit 05838d2c50
28 changed files with 1750 additions and 1175 deletions
+20 -4
View File
@@ -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<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' }
@@ -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<AgentPackageInventory>>()
const downloadAgentPackage = vi.fn<(architecture: string, platform: string) => Promise<AgentPackageInventory>>()
const importAgentPackage = vi.fn<() => Promise<AgentPackageInventory | undefined>>()
const exportAgentPackage = vi.fn<(architecture: string, platform: string) => Promise<void>>()
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(
<AgentPackageInventoryPanel active={false} onBusyChange={onBusyChange} onNotify={onNotify} />
)
expect(getAgentPackageInventory).not.toHaveBeenCalled()
rerender(<AgentPackageInventoryPanel active onBusyChange={onBusyChange} onNotify={onNotify} />)
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<AgentPackageInventory>((resolve) => { resolveInventory = resolve })
)
render(<AgentPackageInventoryPanel />)
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(<AgentPackageInventoryPanel />)
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()
})
})
@@ -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<AgentPackageInventory>()
const [agentInventoryLoading, setAgentInventoryLoading] =
useState(false)
const [agentInventoryError, setAgentInventoryError] =
useState<string>()
const [agentPackageBusy, setAgentPackageBusy] =
useState<string>()
const [agentPackageProgress, setAgentPackageProgress] =
useState<AgentPackageDownloadProgress>()
const agentInventoryRequested = useRef(false)
useEffect(() => {
onBusyChange?.(agentPackageBusy !== undefined)
}, [agentPackageBusy, onBusyChange])
useEffect(() => () => onBusyChange?.(false), [onBusyChange])
const loadAgentInventory = useCallback(async (
refresh = false
): Promise<void> => {
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<void> => {
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<void> => {
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<void> => {
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 (
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>
{t(
'platformFeatures.remoteProjects.agentInventory.title'
)}
</strong>
<small>
{t(
'platformFeatures.remoteProjects.agentInventory.description'
)}
</small>
</div>
<div className="capability-card__actions">
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() => void importAgentPackage()}
type="button"
>
<Upload aria-hidden="true" size={13} />
{t(
'platformFeatures.remoteProjects.agentInventory.import'
)}
</button>
<button
aria-label={t(
'platformFeatures.remoteProjects.agentInventory.refresh'
)}
className="secondary-button"
disabled={
agentInventoryLoading ||
agentPackageBusy !== undefined
}
onClick={() => void loadAgentInventory(true)}
type="button"
>
<RefreshCw
aria-hidden="true"
className={
agentInventoryLoading
? 'bundled-agent-inventory__spinner'
: undefined
}
size={13}
/>
{t(
'platformFeatures.remoteProjects.agentInventory.refresh'
)}
</button>
</div>
</div>
{agentInventory && (
<p className="settings-notice">
{t(
'platformFeatures.remoteProjects.agentInventory.summary',
{
available: agentInventory.entries.filter(
(entry) => entry.state === 'verified'
).length,
total: agentInventory.entries.length
}
)}
</p>
)}
{agentInventoryLoading && !agentInventory ? (
<p className="settings-notice" role="status">
{t(
'platformFeatures.remoteProjects.agentInventory.loading'
)}
</p>
) : agentInventoryError ? (
<p className="settings-warning" role="alert">
{agentInventoryError}
</p>
) : agentInventory ? (
<div
aria-label={t(
'platformFeatures.remoteProjects.agentInventory.listLabel'
)}
className="bundled-agent-inventory"
role="list"
>
{agentInventory.entries.map((entry) => (
<article
className="bundled-agent-inventory__item"
key={`${entry.platform}-${entry.architecture}`}
role="listitem"
>
<div className="bundled-agent-inventory__item-header">
<Cpu aria-hidden="true" size={16} />
<strong>
{entry.platform === 'darwin' ? 'macOS' : 'Linux'} {entry.architecture}
</strong>
<span
className={`bundled-agent-inventory__badge bundled-agent-inventory__badge--${
entry.updateAvailable
? 'update-available'
: agentInventory.catalog.state ===
'available' &&
entry.state === 'verified' &&
entry.latestVersion !== null
? 'up-to-date'
: entry.state
}`}
>
{t(
entry.updateAvailable
? 'platformFeatures.remoteProjects.agentInventory.states.updateAvailable'
: agentInventory.catalog.state ===
'available' &&
entry.state === 'verified' &&
entry.latestVersion !== null
? 'platformFeatures.remoteProjects.agentInventory.states.upToDate'
: `platformFeatures.remoteProjects.agentInventory.states.${entry.state}`
)}
</span>
</div>
<dl>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.agentVersion'
)}
</dt>
<dd>{entry.version ?? '—'}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.latestVersion'
)}
</dt>
<dd>{entry.latestVersion ?? '—'}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.architecture'
)}
</dt>
<dd>{entry.architecture}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.runtimeVersion'
)}
</dt>
<dd>{entry.remoteRuntimeVersion ?? '—'}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.protocol'
)}
</dt>
<dd>
{entry.agentProtocol
? `${entry.agentProtocol.major}.${entry.agentProtocol.minor}`
: '—'}
</dd>
</div>
</dl>
{agentPackageProgress?.architecture ===
entry.architecture &&
agentPackageProgress.platform === entry.platform &&
agentPackageBusy === `${entry.platform}-${entry.architecture}` && (
<p className="settings-notice" role="status">
{t(
`platformFeatures.remoteProjects.agentInventory.progress.${agentPackageProgress.phase}`
)}
</p>
)}
<div className="capability-card__actions">
{(
entry.updateAvailable ||
(
entry.state !== 'verified' &&
entry.latestVersion !== null
) ||
agentInventory.catalog.state !== 'available'
) && (
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() =>
void downloadAgentPackage(
entry.architecture,
entry.platform
)
}
type="button"
>
<Download aria-hidden="true" size={13} />
{t(
entry.updateAvailable
? 'platformFeatures.remoteProjects.agentInventory.updateTo'
: entry.state !== 'verified' &&
entry.latestVersion !== null
? 'platformFeatures.remoteProjects.agentInventory.downloadVersion'
: 'platformFeatures.remoteProjects.agentInventory.update',
{ version: entry.latestVersion }
)}
</button>
)}
{entry.state === 'verified' && (
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() =>
void exportAgentPackage(
entry.architecture,
entry.platform
)
}
type="button"
>
<Upload aria-hidden="true" size={13} />
{t(
'platformFeatures.remoteProjects.agentInventory.export'
)}
</button>
)}
</div>
</article>
))}
</div>
) : null}
{agentInventory?.catalog.state === 'available' && (
<p className="settings-notice" role="status">
{t(
'platformFeatures.remoteProjects.agentInventory.catalog.available'
)}
</p>
)}
{agentInventory?.catalog.state === 'unavailable' && (
<p className="settings-warning" role="alert">
{t(
'platformFeatures.remoteProjects.agentInventory.catalog.unavailable',
{ error: agentInventory.catalog.error }
)}
</p>
)}
</article>
)
}
+11 -12
View File
@@ -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 () => {
+61 -8
View File
@@ -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<WorkspaceView>;
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<string, boolean | undefined>)[id]) && (id === 'magic-notes' ? magicNotesEnabled : isApplicationEnabled(applicationSettings, id)))
id === 'knowledge' || (Boolean((applicationNavigation.pinned as Record<string, boolean | undefined>)[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<void> => {
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<void> => {
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<RightSidebarHandlers>({
onBeforeCloseNotes: async () => {
if (!await guardNoteDraft()) return false;
@@ -7339,6 +7361,38 @@ function App(): React.JSX.Element {
</KeepAliveRoute>
)}
{view === 'device-sharing' && isApplicationEnabled(applicationSettings, 'device-sharing') && <DeviceSharingPage notify={notify} />}
{(view === "remote-hosts" ||
cachedWorkspaceViewKeys.has("remote-hosts")) && (
<KeepAliveRoute
active={view === "remote-hosts"}
onUnsavedChanges={workspaceUnsavedChangesReporters["remote-hosts"]}
route="remote-hosts"
>
<RouteErrorBoundary
key="remote-hosts"
fallback={
<RouteLoadError
message={t("route.loadFailed")}
reloadLabel={t("route.reload")}
/>
}
>
<Suspense
fallback={<RouteLoadingStatus label={t("route.loading")} />}
>
<RemoteHostsPage
active={view === "remote-hosts"}
settings={applicationSettings}
settingsPending={applicationSettingsPending}
settingsLocked={applicationSettingsUnconfirmed}
onUpdateSettings={updateApplicationSettings}
onNotify={notify}
onProjectsDeleted={remoteHostsActions.onProjectsDeleted}
/>
</Suspense>
</RouteErrorBoundary>
</KeepAliveRoute>
)}
{localInferenceOpen && (
<LocalInferencePage
enabled={isApplicationEnabled(applicationSettings, "local-inference")}
@@ -7356,7 +7410,7 @@ function App(): React.JSX.Element {
error={applicationSettingsError}
onClose={() => 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
}
+44 -14
View File
@@ -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(<ApplicationCenter {...handlers} />)
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(<ApplicationCenter {...handlers} settings={{ ...settings, remoteProjectsEnabled: true }} />)
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(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
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(<ApplicationCenter {...handlers} settings={{
...pinnedSettings,
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] },
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'] },
}} />)
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', () => {
+22 -3
View File
@@ -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({
})
}
/>
<span>{t('applications.enable')}</span>
<span>{t(id === 'remote-hosts' ? 'applications.remoteHostsEnable' : 'applications.enable')}</span>
</label>
{id !== 'device-sharing' && <div className="inline-help-label">
<label className="toggle-row">
@@ -143,7 +162,7 @@ export function ApplicationSettingsView({
</label>
<InlineHelp label={t('applications.pin')} id="application-pin-help">{t('applications.pinHelp')}</InlineHelp>
</div>}
<p className="settings-notice">{t(id === 'device-sharing' ? 'applications.deviceSharingHelp' : 'applications.disableHelp')}</p>
<p className="settings-notice">{t(id === 'device-sharing' ? 'applications.deviceSharingHelp' : id === 'remote-hosts' ? 'applications.remoteHostsHelp' : 'applications.disableHelp')}</p>
{id === 'magic-notes' && (
<label className="toggle-row">
<input
@@ -464,7 +483,7 @@ export function ApplicationCenter({
<button
className="secondary-button"
type="button"
disabled={!enabled}
disabled={!isApplicationOpenable(settings, id)}
onClick={() => onOpen(id)}
>
{t('applications.open')}
+11 -1
View File
@@ -35,10 +35,20 @@ it('only shows device sharing when explicitly enabled and removes it on disable'
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
})
it('lists Hosts once settings load, even while remote capability is off', () => {
const onOpen = vi.fn()
const { rerender } = render(<Harness value={null} pending onOpen={onOpen} />)
fireEvent.click(screen.getByText('Launcher'))
expect(screen.queryByRole('menuitem', { name: '主机管理' })).not.toBeInTheDocument()
rerender(<Harness onOpen={onOpen} />)
fireEvent.click(screen.getByRole('menuitem', { name: '主机管理' }))
expect(onOpen).toHaveBeenCalledWith('remote-hosts')
})
it('filters the shared order by enablement only, including reordered always-shown apps', () => {
const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true, heartbeatEnabled: true,
applicationNavigation: { order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] as ApplicationSettings['applicationNavigation']['order'],
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false } } }
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': false } } }
const onOpen = vi.fn()
const { rerender } = render(<Harness value={value} onOpen={onOpen} />)
fireEvent.click(screen.getByText('Launcher'))
+2 -2
View File
@@ -2,7 +2,7 @@ import { useMemo, type RefObject } from 'react'
import { LayoutGrid } from 'lucide-react'
import { useTranslation } from 'react-i18next'
import { defaultApplicationNavigation, type ApplicationSettings, type BuiltInApplicationId } from '../../shared/application-settings-contracts'
import { applicationDefinitions, isApplicationEnabled } from './ApplicationCenter'
import { applicationDefinitions, isApplicationOpenable } from './ApplicationCenter'
import { AnchoredMenu } from './AnchoredMenu'
export function ApplicationMenu({ anchorRef, settings, pending, error, onClose, onOpen, onManage, onRetry }: {
@@ -19,7 +19,7 @@ export function ApplicationMenu({ anchorRef, settings, pending, error, onClose,
const applications = useMemo(() => {
const order = (settings?.applicationNavigation ?? defaultApplicationNavigation).order
return order
.filter(id => isApplicationEnabled(settings, id))
.filter(id => isApplicationOpenable(settings, id))
}, [settings])
return <AnchoredMenu anchorRef={anchorRef} id="application-menu" label={t('applications.title')} onClose={onClose}>
{pending && <p role="status">{t('route.loading')}</p>}
@@ -1,16 +1,10 @@
import {
Cpu,
Download,
FolderOpen,
RefreshCw,
RotateCcw,
Save,
Upload
Save
} from 'lucide-react'
import {
useCallback,
useEffect,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
@@ -18,13 +12,6 @@ import { InlineHelp } from './InlineHelp'
import type {
ApplicationSettings
} from '../../shared/application-settings-contracts'
import type {
AgentPackageDownloadProgress,
AgentPackageInventory
} from '../../shared/agent-package-contracts'
import type {
AgentArchitecture
} from '../../shared/agent-installation-contracts'
import {
canonicalizeShortcutAccelerator,
type GlobalShortcutSettings,
@@ -32,7 +19,6 @@ import {
type GlobalShortcutUpdateErrorCode
} from '../../shared/shortcut'
import type { AppNotificationInput } from './notifications'
import { PageTabs } from './WorkspacePrimitives'
import {
SettingsCategoryHeader,
SettingsWarningList
@@ -43,7 +29,6 @@ type PlatformFeaturesSettingsSectionProps = {
onConversationHtmlRenderingEnabledChange: (
enabled: boolean
) => void
onRemoteProjectsEnabledChange: (enabled: boolean) => void
onWorkspacePathChange: (path: string) => void
workspacePath: string
onNotify?: (notification: AppNotificationInput) => void
@@ -53,8 +38,6 @@ type PlatformFeaturesSettingsSectionProps = {
) => void
}
type PlatformFeaturesTab = 'general' | 'remote-projects'
const shortcutErrorTranslationKeys: Record<
GlobalShortcutUpdateErrorCode,
| 'platformFeatures.shortcut.errors.conflict'
@@ -69,7 +52,6 @@ const shortcutErrorTranslationKeys: Record<
export function PlatformFeaturesSettingsSection({
onConversationHtmlRenderingEnabledChange,
onRemoteProjectsEnabledChange,
onWorkspacePathChange,
workspacePath,
onNotify,
@@ -78,8 +60,6 @@ export function PlatformFeaturesSettingsSection({
}: PlatformFeaturesSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settingsSections')
const { t: settingsT } = useTranslation('settings')
const [activeSection, setActiveSection] =
useState<PlatformFeaturesTab>('general')
const [settings, setSettings] = useState<ApplicationSettings>()
const [shortcutSnapshot, setShortcutSnapshot] =
useState<GlobalShortcutSettingsSnapshot>()
@@ -93,17 +73,6 @@ export function PlatformFeaturesSettingsSection({
: t('platformFeatures.shortcut.errors.serviceUnavailable')
)
const [saving, setSaving] = useState(false)
const [agentInventory, setAgentInventory] =
useState<AgentPackageInventory>()
const [agentInventoryLoading, setAgentInventoryLoading] =
useState(false)
const [agentInventoryError, setAgentInventoryError] =
useState<string>()
const [agentPackageBusy, setAgentPackageBusy] =
useState<string>()
const [agentPackageProgress, setAgentPackageProgress] =
useState<AgentPackageDownloadProgress>()
const agentInventoryRequested = useRef(false)
const [error, setError] = useState<string | undefined>(() =>
window.goodbuddy.updates
? undefined
@@ -135,162 +104,6 @@ export function PlatformFeaturesSettingsSection({
}
}, [t])
const loadAgentInventory = useCallback(async (
refresh = false
): Promise<void> => {
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])
useEffect(() => {
if (
activeSection !== 'remote-projects' ||
agentInventoryRequested.current
) {
return
}
agentInventoryRequested.current = true
void loadAgentInventory()
}, [activeSection, loadAgentInventory])
useEffect(() => {
const subscribe =
window.goodbuddy.sshHosts?.onAgentPackageProgress
return subscribe?.((progress) =>
setAgentPackageProgress(progress)
)
}, [])
const downloadAgentPackage = async (
architecture: AgentArchitecture,
platform: 'linux' | 'darwin'
): Promise<void> => {
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<void> => {
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<void> => {
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)
}
}
useEffect(() => {
const shortcuts = window.goodbuddy.shortcuts
let active = true
@@ -475,69 +288,20 @@ export function PlatformFeaturesSettingsSection({
}
}
const changeRemoteProjects = async (
enabled: boolean
): Promise<void> => {
const updates = window.goodbuddy.updates
if (!updates || !settings) {
return
}
setSaving(true)
setError(undefined)
try {
const nextSettings = await updates.updateSettings({
remoteProjectsEnabled: enabled
})
setSettings(nextSettings)
onRemoteProjectsEnabledChange(
nextSettings.remoteProjectsEnabled
)
} catch {
setError(
t('platformFeatures.errors.saveRemoteProjectsFailed')
)
} finally {
setSaving(false)
}
}
return (
<>
<SettingsCategoryHeader
category="platform-features"
error={error}
headingId="platform-features-heading"
navigation={
<div className="platform-features-tabs">
<PageTabs
ariaLabel={t('platformFeatures.tabs.ariaLabel')}
idPrefix="platform-features"
onChange={setActiveSection}
tabs={[
{
id: 'general',
label: t('platformFeatures.tabs.general')
},
{
id: 'remote-projects',
label: t('platformFeatures.remoteProjects.title')
}
]}
value={activeSection}
variant="segmented"
/>
</div>
}
/>
<SettingsWarningList warnings={settings?.warnings} />
{activeSection === 'general' && (
<section
aria-labelledby="platform-features-tab-general"
className="settings-section"
id="platform-features-panel-general"
role="tabpanel"
>
<section
aria-labelledby="platform-features-heading"
className="settings-section"
id="platform-features-panel-general"
>
<article className="capability-card">
<div className="capability-card__header">
<span className="inline-help-label">
@@ -740,304 +504,7 @@ export function PlatformFeaturesSettingsSection({
</div>
</label>
</article>
</section>
)}
{activeSection === 'remote-projects' && (
<section
aria-labelledby="platform-features-tab-remote-projects"
className="settings-section"
id="platform-features-panel-remote-projects"
role="tabpanel"
>
{settings ? (
<article className="capability-card">
<div className="capability-card__header">
<span className="inline-help-label">
<strong>
{t('platformFeatures.remoteProjects.title')}
</strong>
<InlineHelp label={t('platformFeatures.remoteProjects.title')}>
{t(
'platformFeatures.remoteProjects.description'
)}
</InlineHelp>
</span>
</div>
<label className="toggle-row">
<input
aria-label={t(
'platformFeatures.remoteProjects.title'
)}
checked={settings.remoteProjectsEnabled}
disabled={saving}
onChange={(event) =>
void changeRemoteProjects(event.target.checked)
}
role="switch"
type="checkbox"
/>
<span>
{t('platformFeatures.remoteProjects.enabled')}
</span>
</label>
</article>
) : (
!error && (
<p className="settings-notice" role="status">
{t('platformFeatures.loading')}
</p>
)
)}
<article className="capability-card">
<div className="capability-card__header">
<div>
<strong>
{t(
'platformFeatures.remoteProjects.agentInventory.title'
)}
</strong>
<small>
{t(
'platformFeatures.remoteProjects.agentInventory.description'
)}
</small>
</div>
<div className="capability-card__actions">
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() => void importAgentPackage()}
type="button"
>
<Upload aria-hidden="true" size={13} />
{t(
'platformFeatures.remoteProjects.agentInventory.import'
)}
</button>
<button
aria-label={t(
'platformFeatures.remoteProjects.agentInventory.refresh'
)}
className="secondary-button"
disabled={
agentInventoryLoading ||
agentPackageBusy !== undefined
}
onClick={() => void loadAgentInventory(true)}
type="button"
>
<RefreshCw
aria-hidden="true"
className={
agentInventoryLoading
? 'bundled-agent-inventory__spinner'
: undefined
}
size={13}
/>
{t(
'platformFeatures.remoteProjects.agentInventory.refresh'
)}
</button>
</div>
</div>
{agentInventory && (
<p className="settings-notice">
{t(
'platformFeatures.remoteProjects.agentInventory.summary',
{
available: agentInventory.entries.filter(
(entry) => entry.state === 'verified'
).length,
total: agentInventory.entries.length
}
)}
</p>
)}
{agentInventoryLoading && !agentInventory ? (
<p className="settings-notice" role="status">
{t(
'platformFeatures.remoteProjects.agentInventory.loading'
)}
</p>
) : agentInventoryError ? (
<p className="settings-warning" role="alert">
{agentInventoryError}
</p>
) : agentInventory ? (
<div
aria-label={t(
'platformFeatures.remoteProjects.agentInventory.listLabel'
)}
className="bundled-agent-inventory"
role="list"
>
{agentInventory.entries.map((entry) => (
<article
className="bundled-agent-inventory__item"
key={`${entry.platform}-${entry.architecture}`}
role="listitem"
>
<div className="bundled-agent-inventory__item-header">
<Cpu aria-hidden="true" size={16} />
<strong>
{entry.platform === 'darwin' ? 'macOS' : 'Linux'} {entry.architecture}
</strong>
<span
className={`bundled-agent-inventory__badge bundled-agent-inventory__badge--${
entry.updateAvailable
? 'update-available'
: agentInventory.catalog.state ===
'available' &&
entry.state === 'verified' &&
entry.latestVersion !== null
? 'up-to-date'
: entry.state
}`}
>
{t(
entry.updateAvailable
? 'platformFeatures.remoteProjects.agentInventory.states.updateAvailable'
: agentInventory.catalog.state ===
'available' &&
entry.state === 'verified' &&
entry.latestVersion !== null
? 'platformFeatures.remoteProjects.agentInventory.states.upToDate'
: `platformFeatures.remoteProjects.agentInventory.states.${entry.state}`
)}
</span>
</div>
<dl>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.agentVersion'
)}
</dt>
<dd>{entry.version ?? '—'}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.latestVersion'
)}
</dt>
<dd>{entry.latestVersion ?? '—'}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.architecture'
)}
</dt>
<dd>{entry.architecture}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.runtimeVersion'
)}
</dt>
<dd>{entry.remoteRuntimeVersion ?? '—'}</dd>
</div>
<div>
<dt>
{t(
'platformFeatures.remoteProjects.agentInventory.fields.protocol'
)}
</dt>
<dd>
{entry.agentProtocol
? `${entry.agentProtocol.major}.${entry.agentProtocol.minor}`
: '—'}
</dd>
</div>
</dl>
{agentPackageProgress?.architecture ===
entry.architecture &&
agentPackageProgress.platform === entry.platform &&
agentPackageBusy === `${entry.platform}-${entry.architecture}` && (
<p className="settings-notice" role="status">
{t(
`platformFeatures.remoteProjects.agentInventory.progress.${agentPackageProgress.phase}`
)}
</p>
)}
<div className="capability-card__actions">
{(
entry.updateAvailable ||
(
entry.state !== 'verified' &&
entry.latestVersion !== null
) ||
agentInventory.catalog.state !== 'available'
) && (
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() =>
void downloadAgentPackage(
entry.architecture,
entry.platform
)
}
type="button"
>
<Download aria-hidden="true" size={13} />
{t(
entry.updateAvailable
? 'platformFeatures.remoteProjects.agentInventory.updateTo'
: entry.state !== 'verified' &&
entry.latestVersion !== null
? 'platformFeatures.remoteProjects.agentInventory.downloadVersion'
: 'platformFeatures.remoteProjects.agentInventory.update',
{ version: entry.latestVersion }
)}
</button>
)}
{entry.state === 'verified' && (
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() =>
void exportAgentPackage(
entry.architecture,
entry.platform
)
}
type="button"
>
<Upload aria-hidden="true" size={13} />
{t(
'platformFeatures.remoteProjects.agentInventory.export'
)}
</button>
)}
</div>
</article>
))}
</div>
) : null}
{agentInventory?.catalog.state === 'available' && (
<p className="settings-notice" role="status">
{t(
'platformFeatures.remoteProjects.agentInventory.catalog.available'
)}
</p>
)}
{agentInventory?.catalog.state === 'unavailable' && (
<p className="settings-warning" role="alert">
{t(
'platformFeatures.remoteProjects.agentInventory.catalog.unavailable',
{ error: agentInventory.catalog.error }
)}
</p>
)}
</article>
</section>
)}
</section>
</>
)
}
+118
View File
@@ -0,0 +1,118 @@
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AgentPackageInventory } from '../../shared/agent-package-contracts'
import {
applicationSettingsSchema,
defaultLocalToolEnvironmentSettings,
type ApplicationSettings
} from '../../shared/application-settings-contracts'
import { changeUiLocale } from './i18n'
import { RemoteHostsPage } from './RemoteHostsPage'
import { WorkspaceUnsavedChangesContext } from './workspace-unsaved-changes'
const settings = applicationSettingsSchema.parse({
checkUpdatesOnStartup: false,
updateSource: 'github',
modelDownloadSource: 'modelscope',
localToolEnvironment: defaultLocalToolEnvironmentSettings,
conversationHtmlRenderingEnabled: true,
remoteProjectsEnabled: false
})
const inventory: AgentPackageInventory = {
checkedAt: '2026-08-24T00:00:00.000Z',
catalog: { state: 'available', checkedAt: '2026-08-24T00:00:00.000Z', error: null },
entries: []
}
const getSnapshot = vi.fn(async () => ({ hosts: [], secureStorageAvailable: true }))
const getAgentPackageInventory = vi.fn(async () => inventory)
function renderPage(
overrides: Partial<Parameters<typeof RemoteHostsPage>[0]> & { value?: ApplicationSettings } = {},
report = vi.fn()
) {
const onUpdateSettings = vi.fn(async () => true)
const { value = settings, ...rest } = overrides
const view = render(
<WorkspaceUnsavedChangesContext.Provider value={report}>
<RemoteHostsPage
active
settings={value}
settingsPending={false}
settingsLocked={false}
onUpdateSettings={onUpdateSettings}
onNotify={vi.fn()}
{...rest}
/>
</WorkspaceUnsavedChangesContext.Provider>
)
return { ...view, onUpdateSettings, report }
}
describe('RemoteHostsPage', () => {
beforeEach(async () => {
await changeUiLocale('zh-CN')
vi.clearAllMocks()
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
sshHosts: {
getSnapshot,
getAgentPackageInventory,
onAgentPackageProgress: vi.fn(() => () => {}),
onRemoteEnvironmentUpdateProgress: vi.fn(() => () => {})
}
}
})
})
afterEach(() => cleanup())
it('offers enablement on the Hosts tab without calling host services while remote capability is off', async () => {
const { onUpdateSettings } = renderPage()
expect(screen.getByRole('heading', { level: 1, name: '主机管理' })).toBeInTheDocument()
expect(screen.getByRole('tab', { name: '主机' })).toHaveAttribute('aria-selected', 'true')
expect(screen.getByText('远程能力未启用')).toBeVisible()
expect(getSnapshot).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: '启用远程能力' }))
expect(onUpdateSettings).toHaveBeenCalledWith({ remoteProjectsEnabled: true })
})
it('shows hosts when enabled and keeps every tab panel mounted while switching', async () => {
renderPage({ value: { ...settings, remoteProjectsEnabled: true } })
expect(await screen.findByText('尚未配置 SSH 主机')).toBeInTheDocument()
expect(getSnapshot).toHaveBeenCalledOnce()
expect(getAgentPackageInventory).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('tab', { name: 'Agent 离线包' }))
expect(await screen.findByText('GoodBuddy Agent 包')).toBeVisible()
await waitFor(() => expect(getAgentPackageInventory).toHaveBeenCalledOnce())
expect(screen.getByText('尚未配置 SSH 主机')).not.toBeVisible()
fireEvent.click(screen.getByRole('tab', { name: '主机' }))
expect(screen.getByText('尚未配置 SSH 主机')).toBeVisible()
// Panels stayed mounted: nothing was reloaded.
expect(getSnapshot).toHaveBeenCalledOnce()
expect(getAgentPackageInventory).toHaveBeenCalledOnce()
})
it('lets Agent packages be managed while remote capability is off', async () => {
renderPage()
fireEvent.click(screen.getByRole('tab', { name: 'Agent 离线包' }))
expect(await screen.findByText('GoodBuddy Agent 包')).toBeVisible()
expect(getAgentPackageInventory).toHaveBeenCalledOnce()
})
it('has only Hosts and Agent packages tabs; the switch stays in application settings', () => {
renderPage()
expect(screen.getAllByRole('tab').map((tab) => tab.textContent)).toEqual(['主机', 'Agent 离线包'])
expect(screen.queryByRole('switch')).not.toBeInTheDocument()
})
it('pins the route while a host draft is open so the page cache keeps it', async () => {
const report = vi.fn()
renderPage({ value: { ...settings, remoteProjectsEnabled: true } }, report)
await screen.findByText('尚未配置 SSH 主机')
fireEvent.click(screen.getAllByRole('button', { name: '添加主机' })[0]!)
fireEvent.change(screen.getByLabelText('主机名称'), { target: { value: '未保存的主机' } })
await waitFor(() => expect(report).toHaveBeenLastCalledWith(expect.any(String), true))
})
})
+131
View File
@@ -0,0 +1,131 @@
import { Server } from 'lucide-react'
import { useCallback, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { AgentPackageInventoryPanel } from './AgentPackageInventoryPanel'
import type {
ApplicationSettings,
ApplicationSettingsUpdate
} from '../../shared/application-settings-contracts'
import type { AppNotificationInput } from './notifications'
import { SshHostsSettingsSection } from './SshHostsSettingsSection'
import { useWorkspaceUnsavedChanges } from './workspace-unsaved-changes'
import { EmptyState, PageHeader, PageShell, PageTabs } from './WorkspacePrimitives'
import './remote-hosts.css'
export type RemoteHostsTab = 'hosts' | 'packages'
/**
* Hosts application: SSH hosts and local Agent packages. The shared remote
* capability switch lives in the application settings (Application Center).
* Host operations stay gated by remoteProjectsEnabled in Main, so the Hosts
* tab offers enabling it instead of failing.
*/
export function RemoteHostsPage({
active,
settings,
settingsPending,
settingsLocked,
onUpdateSettings,
onNotify,
onHostUpdated,
onProjectsDeleted
}: {
active: boolean
settings?: ApplicationSettings
settingsPending: boolean
settingsLocked: boolean
onUpdateSettings: (patch: ApplicationSettingsUpdate) => Promise<boolean>
onNotify: (notification: AppNotificationInput) => void
onHostUpdated?: (hostId: string) => void
onProjectsDeleted?: (projectIds: string[]) => void
}): React.JSX.Element {
const { t } = useTranslation('app')
const [tab, setTab] = useState<RemoteHostsTab>('hosts')
const [hostDraftDirty, setHostDraftDirty] = useState(false)
const [hostsBusy, setHostsBusy] = useState(false)
const [packagesBusy, setPackagesBusy] = useState(false)
const remoteEnabled = settings?.remoteProjectsEnabled === true
const [packagesVisited, setPackagesVisited] = useState(false)
// Pins the route while a host draft, installation, or package transfer is
// in progress so the cache never discards it.
useWorkspaceUnsavedChanges(hostDraftDirty || hostsBusy || packagesBusy)
// Derived during render: the package inventory loads once its tab is first shown.
if (active && tab === 'packages' && !packagesVisited) setPackagesVisited(true)
const handleHostsDirty = useCallback((dirty: boolean) => setHostDraftDirty(dirty), [])
const handleHostsBusy = useCallback((busy: boolean) => setHostsBusy(busy), [])
const handlePackagesBusy = useCallback((busy: boolean) => setPackagesBusy(busy), [])
return (
<PageShell variant="standard">
<PageHeader
headingId="remote-hosts-title"
icon={<Server size={18} />}
title={t('remoteHosts.title')}
description={t('remoteHosts.description')}
scope={{ kind: 'global' }}
/>
<div className="remote-hosts-tabs">
<PageTabs
ariaLabel={t('remoteHosts.tabsLabel')}
idPrefix="remote-hosts"
onChange={setTab}
tabs={[
{ id: 'hosts', label: t('remoteHosts.tabs.hosts') },
{ id: 'packages', label: t('remoteHosts.tabs.packages') }
]}
value={tab}
/>
</div>
{/* Panels stay mounted so in-flight installs and transfers keep reporting progress. */}
<section
aria-labelledby="remote-hosts-tab-hosts"
className="remote-hosts-panel"
hidden={tab !== 'hosts'}
id="remote-hosts-panel-hosts"
role="tabpanel"
>
{remoteEnabled ? (
<SshHostsSettingsSection
onBusyChange={handleHostsBusy}
onDirtyChange={handleHostsDirty}
onHostUpdated={onHostUpdated}
onNotify={onNotify}
onProjectsDeleted={onProjectsDeleted}
/>
) : (
<EmptyState
action={
<button
className="primary-button"
disabled={!settings || settingsPending || settingsLocked}
onClick={() => void onUpdateSettings({ remoteProjectsEnabled: true })}
type="button"
>
{t('remoteHosts.disabled.enable')}
</button>
}
description={t('remoteHosts.disabled.description')}
icon={<Server size={22} />}
title={t('remoteHosts.disabled.title')}
/>
)}
</section>
<section
aria-labelledby="remote-hosts-tab-packages"
className="remote-hosts-panel"
hidden={tab !== 'packages'}
id="remote-hosts-panel-packages"
role="tabpanel"
>
<AgentPackageInventoryPanel
active={packagesVisited}
onBusyChange={handlePackagesBusy}
onNotify={onNotify}
/>
</section>
</PageShell>
)
}
+33 -363
View File
@@ -382,8 +382,7 @@ const heartbeatSettingsProps = {
onUpdateProject: vi.fn(async () => {
throw new Error('Project update is not used in this test')
}),
projects: [],
remoteProjectsEnabled: true
projects: []
}
const assistantExpert: AssistantExpert = {
id: '00000000-0000-4000-8000-000000000101',
@@ -2035,12 +2034,37 @@ describe('SettingsPanel runtime files', () => {
expect(within(navigation).getAllByRole('tab').map(tab => tab.id)).toEqual([
'settings-tab-appearance', 'settings-tab-platform-features',
'settings-tab-model', 'settings-tab-context-control', 'settings-tab-runtime',
'settings-tab-ssh-hosts',
'settings-tab-document-parsing', 'settings-tab-channels', 'settings-tab-roles',
'settings-tab-capabilities', 'settings-tab-about'
])
})
it('keeps hosts, Agent packages, and the remote switch out of settings', async () => {
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
expect(
screen.queryByRole('tab', { name: '主机与远程执行' })
).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
await screen.findByLabelText('默认工作区目录')
expect(
screen.queryByRole('tab', { name: '远程项目' })
).not.toBeInTheDocument()
expect(
screen.queryByRole('switch', { name: '远程项目' })
).not.toBeInTheDocument()
expect(screen.queryByText('GoodBuddy Agent 包')).not.toBeInTheDocument()
expect(getAgentPackageInventory).not.toHaveBeenCalled()
})
it('omits the redundant close-only footer on passive settings pages', () => {
render(
<SettingsPanel
@@ -2356,304 +2380,6 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument()
})
it('persists Remote Projects from its own tab and notifies its parent', async () => {
const onRemoteProjectsEnabledChange = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
onRemoteProjectsEnabledChange={
onRemoteProjectsEnabledChange
}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
screen.getByRole('tab', { name: '远程项目' })
)
const remoteProjectsSwitch = await screen.findByRole('switch', {
name: '远程项目'
})
expect(remoteProjectsSwitch).not.toBeChecked()
expect(
await screen.findByText('GoodBuddy Agent 包')
).toBeInTheDocument()
expect(
screen.getByText('当前 1 / 2 个平台与架构目标可用。')
).toBeInTheDocument()
expect(screen.getByText('Linux x64')).toBeInTheDocument()
expect(screen.getByText('Linux arm64')).toBeInTheDocument()
expect(screen.getByText('有更新')).toBeInTheDocument()
expect(screen.getByText('未下载')).toBeInTheDocument()
expect(getAgentPackageInventory).toHaveBeenCalledOnce()
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(1, 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"]')
const arm64Card = screen
.getByText('Linux arm64')
.closest('[role="listitem"]')
expect(x64Card).not.toBeNull()
expect(arm64Card).not.toBeNull()
downloadAgentPackage.mockResolvedValueOnce({
...agentPackageInventory,
entries: agentPackageInventory.entries.map((entry) =>
entry.architecture === 'x64'
? {
...entry,
version: '0.11.4',
updateAvailable: false
}
: entry
)
})
fireEvent.click(
within(x64Card as HTMLElement).getByRole('button', {
name: '更新到 0.11.4'
})
)
await waitFor(() =>
expect(downloadAgentPackage).toHaveBeenCalledWith('x64', 'linux')
)
expect(
await within(x64Card as HTMLElement).findByText('已是最新')
).toBeInTheDocument()
expect(
within(x64Card as HTMLElement).queryByRole('button', {
name: /更新到/u
})
).not.toBeInTheDocument()
fireEvent.click(
within(x64Card as HTMLElement).getByRole('button', {
name: '导出离线包'
})
)
await waitFor(() =>
expect(exportAgentPackage).toHaveBeenCalledWith('x64', 'linux')
)
fireEvent.click(
within(arm64Card as HTMLElement).getByRole('button', {
name: '下载 0.11.4'
})
)
await waitFor(() =>
expect(downloadAgentPackage).toHaveBeenCalledWith('arm64', 'linux')
)
fireEvent.click(
screen.getByRole('button', { name: '导入离线包' })
)
await waitFor(() =>
expect(importAgentPackage).toHaveBeenCalledOnce()
)
fireEvent.click(remoteProjectsSwitch)
await waitFor(() =>
expect(updateApplicationSettings).toHaveBeenCalledWith({
remoteProjectsEnabled: true
})
)
expect(onRemoteProjectsEnabledChange).toHaveBeenCalledWith(true)
expect(remoteProjectsSwitch).toBeChecked()
})
it('shows Agent package loading and recovers from a refresh error', async () => {
let resolveInventory:
| ((inventory: AgentPackageInventory) => void)
| undefined
getAgentPackageInventory.mockReturnValueOnce(
new Promise<AgentPackageInventory>((resolve) => {
resolveInventory = resolve
})
)
render(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
screen.getByRole('tab', { name: '远程项目' })
)
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(
<SettingsPanel
{...heartbeatSettingsProps}
open
onClearLocalData={vi.fn(async () => {})}
onClose={vi.fn()}
onSaved={vi.fn()}
/>
)
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
fireEvent.click(
screen.getByRole('tab', { name: '远程项目' })
)
expect(
await screen.findByText(/network unavailable/u)
).toBeInTheDocument()
const x64Card = screen
.getByText('Linux x64')
.closest('[role="listitem"]')
expect(
within(x64Card as HTMLElement).getByRole('button', {
name: '检查并更新'
})
).toBeInTheDocument()
expect(
within(x64Card as HTMLElement).getByRole('button', {
name: '导出离线包'
})
).toBeInTheDocument()
})
it('shows the SSH Hosts category and its local capability boundary only while Remote Projects is enabled', () => {
const commonProps = {
...heartbeatSettingsProps,
open: true,
onClearLocalData: vi.fn(async () => {}),
onClose: vi.fn(),
onSaved: vi.fn()
}
const { rerender } = render(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled={false}
/>
)
expect(
screen.queryByRole('tab', { name: '主机与远程执行' })
).not.toBeInTheDocument()
rerender(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled
/>
)
expect(
screen.getByRole('tab', { name: '主机与远程执行' })
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: '主机与远程执行' })
)
expect(
screen.getByText(
'管理 SSH 主机、固定主机密钥并验证远端执行环境;本机配置的 Skills 和 MCP 不会应用于远程主机'
)
).toBeInTheDocument()
})
it('falls back to Platform features when Remote Projects is disabled from SSH Hosts', async () => {
const commonProps = {
...heartbeatSettingsProps,
initialCategory: 'ssh-hosts' as const,
open: true,
onClearLocalData: vi.fn(async () => {}),
onClose: vi.fn(),
onSaved: vi.fn()
}
const { rerender } = render(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled
/>
)
await screen.findByText('尚未配置 SSH 主机')
rerender(
<SettingsPanel
{...commonProps}
remoteProjectsEnabled={false}
/>
)
await waitFor(() =>
expect(
screen.getByRole('tab', { name: '平台功能' })
).toHaveAttribute('aria-selected', 'true')
)
expect(
screen.queryByRole('tab', { name: '主机与远程执行' })
).not.toBeInTheDocument()
fireEvent.click(
screen.getByRole('tab', { name: '远程项目' })
)
expect(
await screen.findByRole('switch', {
name: '远程项目'
})
).toBeInTheDocument()
})
it('records physical Control separately from Command on macOS', async () => {
shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot,
@@ -2782,59 +2508,6 @@ describe('SettingsPanel runtime files', () => {
expect(onClose).not.toHaveBeenCalled()
})
it('protects unsaved SSH host drafts across settings navigation', async () => {
const onClose = vi.fn()
render(
<SettingsPanel
{...heartbeatSettingsProps}
initialCategory="ssh-hosts"
open
onClearLocalData={vi.fn(async () => {})}
onClose={onClose}
onSaved={vi.fn()}
/>
)
await screen.findByText('尚未配置 SSH 主机')
fireEvent.click(
screen.getAllByRole('button', { name: '添加主机' })[0]!
)
fireEvent.change(screen.getByLabelText('主机名称'), {
target: { value: '尚未保存的主机' }
})
const appearanceTab = screen.getByRole('tab', { name: '外观' })
fireEvent.click(appearanceTab)
expect(
screen.getByRole('tab', { name: '主机与远程执行' })
).toHaveAttribute('aria-selected', 'true')
expect(screen.getByRole('alert')).toHaveTextContent(
'当前设置有未保存更改'
)
expect(screen.getByLabelText('主机名称')).toHaveValue(
'尚未保存的主机'
)
fireEvent.click(
screen.getByRole('button', { name: '继续编辑' })
)
fireEvent.click(appearanceTab)
fireEvent.click(
screen.getByRole('button', { name: '放弃更改并切换' })
)
expect(appearanceTab).toHaveAttribute('aria-selected', 'true')
await waitFor(() => expect(appearanceTab).toHaveFocus())
fireEvent.click(
screen.getByRole('tab', { name: '主机与远程执行' })
)
await screen.findByText('尚未配置 SSH 主机')
expect(screen.queryByLabelText('主机名称')).not.toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
expect(onClose).toHaveBeenCalledOnce()
})
it('keeps shortcut input after a registration conflict', async () => {
updateShortcutSettings.mockResolvedValueOnce({
ok: false,
@@ -2983,15 +2656,15 @@ describe('SettingsPanel runtime files', () => {
runtimeTab.focus()
fireEvent.keyDown(runtimeTab, { key: 'ArrowRight' })
const sshHostsTab = screen.getByRole('tab', {
name: '主机与远程执行'
const documentParsingTab = screen.getByRole('tab', {
name: '文档解析'
})
expect(sshHostsTab).toHaveFocus()
expect(sshHostsTab).toHaveAttribute('aria-selected', 'true')
expect(sshHostsTab).toHaveAttribute('tabindex', '0')
expect(documentParsingTab).toHaveFocus()
expect(documentParsingTab).toHaveAttribute('aria-selected', 'true')
expect(documentParsingTab).toHaveAttribute('tabindex', '0')
expect(runtimeTab).toHaveAttribute('tabindex', '-1')
fireEvent.keyDown(sshHostsTab, { key: 'End' })
fireEvent.keyDown(documentParsingTab, { key: 'End' })
const aboutTab = screen.getByRole('tab', { name: '关于与更新' })
expect(aboutTab).toHaveFocus()
expect(aboutTab).toHaveAttribute('aria-selected', 'true')
@@ -4421,9 +4094,6 @@ describe('SettingsPanel runtime files', () => {
.getByRole('heading', { level: 2, name: '平台功能' })
.closest('.settings-category-header')
expect(platformHeader).toHaveClass('settings-category-header--sticky')
expect(
platformHeader?.querySelector('.platform-features-tabs')
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '能力与工具' }))
const capabilitiesHeader = screen
+6 -41
View File
@@ -61,7 +61,6 @@ import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { DshMarketplaceSection } from './DshMarketplaceSection'
import { displayErrorMessage } from './error-message'
import { SshHostsSettingsSection } from './SshHostsSettingsSection'
import {
RuntimeCustomizationSection,
type RuntimeCustomizationSectionHandle
@@ -227,7 +226,6 @@ type SettingsPanelProps = {
initialCategory?: SettingsCategoryId
initialChannel?: ProjectChannel
onClose: () => void
onProjectsDeleted?: (projectIds: string[]) => void
onSaved: (settings: RuntimeSettings) => void
onNotify?: (notification: AppNotificationInput) => void
onUpdateProject: (
@@ -253,11 +251,9 @@ type SettingsPanelProps = {
preferences: BrandingPreferences
) => boolean
magicNotesEnabled?: boolean
remoteProjectsEnabled?: boolean
onConversationHtmlRenderingEnabledChange?: (
enabled: boolean
) => void
onRemoteProjectsEnabledChange?: (enabled: boolean) => void
onShortcutSettingsChanged?: (
snapshot: GlobalShortcutSettingsSnapshot
) => void
@@ -647,7 +643,6 @@ function SettingsPanelView({
initialCategory,
initialChannel,
onClose,
onProjectsDeleted = () => {},
onSaved,
onNotify = () => {},
onUpdateProject,
@@ -668,9 +663,7 @@ function SettingsPanelView({
brandingFallbackLogo,
onBrandingPreferencesChange = () => false,
magicNotesEnabled = false,
remoteProjectsEnabled = false,
onConversationHtmlRenderingEnabledChange = () => {},
onRemoteProjectsEnabledChange = () => {},
onShortcutSettingsChanged = () => {},
onLeaveRequestReady = () => {}
}: SettingsPanelProps): React.JSX.Element | null {
@@ -797,15 +790,8 @@ function SettingsPanelView({
const [detection, setDetection] = useState<AgentRuntimeDetection>()
const [detecting, setDetecting] = useState(false)
const [selectedTab, setActiveTab] =
useState<SettingsCategoryId>(
initialCategory === 'ssh-hosts' && !remoteProjectsEnabled
? 'platform-features'
: initialCategory ?? 'runtime'
)
const activeTab =
selectedTab === 'ssh-hosts' && !remoteProjectsEnabled
? 'platform-features'
: selectedTab
useState<SettingsCategoryId>(initialCategory ?? 'runtime')
const activeTab = selectedTab
const [modelType, setModelType] = useState<ModelType>('llm')
const [modelDownloadSourceOpen, setModelDownloadSourceOpen] = useState(false)
const [modelDownloadSource, setModelDownloadSource] = useState<ModelDownloadSource>()
@@ -829,7 +815,6 @@ function SettingsPanelView({
useState(false)
const [documentParsingDirty, setDocumentParsingDirty] =
useState(false)
const [sshHostsDirty, setSshHostsDirty] = useState(false)
const [pendingLeave, setPendingLeave] = useState<
| { kind: 'close' }
| { kind: 'navigate'; category: SettingsCategoryId; openModelDownloadSource?: boolean }
@@ -915,7 +900,6 @@ function SettingsPanelView({
const categoryRendersOwnHeader =
activeTab === 'platform-features' ||
activeTab === 'document-parsing' ||
activeTab === 'ssh-hosts' ||
activeTab === 'channels' ||
activeTab === 'capabilities' ||
activeTab === 'about'
@@ -1018,22 +1002,19 @@ function SettingsPanelView({
runtimeCustomizationDirty ||
brandingDirty ||
platformFeaturesDirty ||
documentParsingDirty ||
sshHostsDirty
documentParsingDirty
const navigationWouldLoseDraft =
runtimeCustomizationDirty ||
brandingDirty ||
platformFeaturesDirty ||
documentParsingDirty ||
sshHostsDirty
documentParsingDirty
const unsavedCloseMessage =
runtimeCustomizationDirty &&
!runtimeDraftDirty &&
!speechModelSelectionDirty &&
!brandingDirty &&
!platformFeaturesDirty &&
!documentParsingDirty &&
!sshHostsDirty
!documentParsingDirty
? t('runtime.customization.unsavedClose')
: t('unsaved.close')
@@ -1077,9 +1058,7 @@ function SettingsPanelView({
if (requestTabChange('model', undefined, true)) setModelDownloadSourceOpen(true)
}
const visibleSettingsCategories = getSettingsCategoryList(
remoteProjectsEnabled
)
const visibleSettingsCategories = getSettingsCategoryList()
const settingsTabs = visibleSettingsCategories.map(({ id }) => id)
const handleTabKeyDown = (
@@ -1131,7 +1110,6 @@ function SettingsPanelView({
setSpeechModelSelectionDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setSshHostsDirty(false)
setPendingLeave(undefined)
setAgentRuntimeType('opencode')
hydrateSettings(value)
@@ -1300,7 +1278,6 @@ function SettingsPanelView({
setBrandingDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setSshHostsDirty(false)
setPendingLeave(undefined)
setError(undefined)
}
@@ -1326,7 +1303,6 @@ function SettingsPanelView({
setBrandingDirty(false)
setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false)
setSshHostsDirty(false)
const leave = pendingLeave
setPendingLeave(undefined)
setError(undefined)
@@ -2464,9 +2440,6 @@ function SettingsPanelView({
onConversationHtmlRenderingEnabledChange={
onConversationHtmlRenderingEnabledChange
}
onRemoteProjectsEnabledChange={
onRemoteProjectsEnabledChange
}
onWorkspacePathChange={setWorkspacePath}
workspacePath={workspacePath}
onNotify={onNotify}
@@ -4159,14 +4132,6 @@ function SettingsPanelView({
/>
)}
{remoteProjectsEnabled && activeTab === 'ssh-hosts' && (
<SshHostsSettingsSection
onDirtyChange={setSshHostsDirty}
onNotify={onNotify}
onProjectsDeleted={onProjectsDeleted}
/>
)}
{activeTab === 'platform-features' && (
<>
<div className="settings-section settings-section--danger">
@@ -204,10 +204,18 @@ async function inspectAndConfirmFirstKey(): Promise<void> {
fireEvent.click(continueButton)
}
function expandHost(host: HTMLElement, name: string): void {
const toggle = within(host).queryByRole('button', {
name: `展开 ${name} 的详情`
})
if (toggle) fireEvent.click(toggle)
}
async function refreshHostEnvironment(
name = 'Build host'
): Promise<HTMLElement> {
const host = await screen.findByRole('region', { name })
expandHost(host, name)
fireEvent.click(
within(host).getByRole('button', {
name: `刷新 ${name} 的远程运行环境版本`
@@ -695,7 +703,7 @@ describe('SshHostsSettingsSection', () => {
})
fireEvent.click(
within(host).getByRole('button', { name: '删除' })
within(host).getByRole('button', { name: '删除 Build host' })
)
const confirmation = screen.getByRole('alertdialog')
expect(
@@ -737,7 +745,8 @@ describe('SshHostsSettingsSection', () => {
name: '验证 Build host'
})
).toHaveTextContent('验证并保存')
expect(screen.getByText('需要重新验证')).toBeInTheDocument()
// Shown as a summary badge and in the expanded details.
expect(screen.getAllByText('需要重新验证')).toHaveLength(2)
expect(getRemoteEnvironment).not.toHaveBeenCalled()
})
@@ -773,6 +782,10 @@ describe('SshHostsSettingsSection', () => {
expect(within(host).getByText('0.10.4')).toBeInTheDocument()
expect(within(host).getByText('0.11.1')).toBeInTheDocument()
expect(within(host).getAllByText('1.18.29')).toHaveLength(2)
const versions = within(host).getByRole('table')
expect(within(versions).getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual(['组件', 'Host 已安装', 'GoodBuddy 所需', '状态'])
expect(within(versions).getByRole('rowheader', { name: 'GoodBuddy Agent' })).toBeInTheDocument()
expect(within(versions).getByRole('rowheader', { name: 'OpenCode Runtime' })).toBeInTheDocument()
expect(within(host).getByText('安装方式')).toBeInTheDocument()
expect(
within(host)
@@ -822,13 +835,28 @@ describe('SshHostsSettingsSection', () => {
})
render(<SshHostsSettingsSection />)
await screen.findByRole('region', { name: 'Deploy host' })
const deployHost = await screen.findByRole('region', { name: 'Deploy host' })
expect(getRemoteEnvironment).not.toHaveBeenCalled()
// Several hosts start as one-line summaries.
expect(within(deployHost).getByText('deploy.example.com', { exact: false })).toBeInTheDocument()
expect(within(deployHost).getByText('环境未检查')).toBeInTheDocument()
expect(
screen.getAllByText(
screen.queryByText(
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。'
)
).toHaveLength(2)
).not.toBeInTheDocument()
const toggle = within(deployHost).getByRole('button', { name: '展开 Deploy host 的详情' })
expect(toggle).toHaveAttribute('aria-expanded', 'false')
fireEvent.click(toggle)
expect(
within(deployHost).getByRole('button', { name: '收起 Deploy host 的详情' })
).toHaveAttribute('aria-expanded', 'true')
expect(
within(deployHost).getByText(
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。'
)
).toBeInTheDocument()
expect(getRemoteEnvironment).not.toHaveBeenCalled()
await refreshHostEnvironment()
expect(getRemoteEnvironment).toHaveBeenCalledTimes(1)
@@ -1281,7 +1309,7 @@ describe('SshHostsSettingsSection', () => {
})
).toBeDisabled()
expect(
within(buildHost).getByRole('button', { name: '删除' })
within(buildHost).getByRole('button', { name: '删除 Build host' })
).toBeDisabled()
expect(
within(deployHost).getByRole('button', {
+284 -145
View File
@@ -2,11 +2,12 @@ import {
Bot,
Boxes,
CheckCircle2,
ChevronRight,
Download,
LoaderCircle,
Pencil,
Plus,
RefreshCw,
Search,
Server,
ShieldCheck,
X
@@ -28,9 +29,9 @@ import type {
SshHostValidationResult
} from '../../shared/ssh-host-contracts'
import type { AppNotificationInput } from './notifications'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import { displayErrorMessage } from './error-message'
import { SshHostDialog } from './SshHostDialog'
import './remote-hosts.css'
import {
getCachedSshHostRemoteEnvironments,
removeCachedSshHostRemoteEnvironment,
@@ -43,6 +44,7 @@ import {
} from './WorkspacePrimitives'
type SshHostsSettingsSectionProps = {
onBusyChange?: (busy: boolean) => void
onDirtyChange?: (dirty: boolean) => void
onHostUpdated?: (hostId: string) => void
onNotify?: (notification: AppNotificationInput) => void
@@ -95,6 +97,7 @@ function upsertHost(hosts: SshHost[], host: SshHost): SshHost[] {
}
export function SshHostsSettingsSection({
onBusyChange,
onDirtyChange,
onHostUpdated,
onNotify,
@@ -110,6 +113,14 @@ export function SshHostsSettingsSection({
const [loadError, setLoadError] = useState<string>()
const [error, setError] = useState<string>()
const [editingId, setEditingId] = useState<string | 'new'>()
const [query, setQuery] = useState('')
// Hosts start collapsed so long lists stay scannable; a single host starts open.
const [expandedOverrides, setExpandedOverrides] = useState<
ReadonlyMap<string, boolean>
>(() => new Map())
const setHostExpanded = (hostId: string, expanded: boolean): void => {
setExpandedOverrides((current) => new Map(current).set(hostId, expanded))
}
const [busyHostId, setBusyHostId] = useState<string>()
const [confirmingDeleteId, setConfirmingDeleteId] =
useState<string>()
@@ -510,6 +521,8 @@ export function SshHostsSettingsSection({
const edited = hosts.some((host) => host.id === result.host.id)
removeCachedSshHostRemoteEnvironment(result.host.id)
setHosts((current) => upsertHost(current, result.host))
// Show the validation result and next step for the host just saved.
setHostExpanded(result.host.id, true)
setTestResults((current) => ({
...current,
[result.host.id]: result.connection
@@ -604,27 +617,53 @@ export function SshHostsSettingsSection({
const hasActiveRemoteEnvironmentUpdate =
remoteEnvironmentUpdate !== undefined
useEffect(() => {
onBusyChange?.(hasActiveRemoteEnvironmentUpdate)
}, [hasActiveRemoteEnvironmentUpdate, onBusyChange])
useEffect(() => () => onBusyChange?.(false), [onBusyChange])
const normalizedQuery = query.trim().toLocaleLowerCase()
const visibleHosts = normalizedQuery
? hosts.filter((host) =>
`${host.name} ${host.username}@${host.hostname}:${host.port}`
.toLocaleLowerCase()
.includes(normalizedQuery)
)
: hosts
return (
<div className="ssh-hosts-settings">
<SettingsCategoryHeader
actions={
<button
className="primary-button"
disabled={loading || Boolean(editingId) || !api}
onClick={() => {
setEditingId('new')
setError(undefined)
}}
type="button"
>
<Plus aria-hidden="true" size={14} />
{t('sshHosts.actions.add')}
</button>
}
category="ssh-hosts"
error={error}
headingId="ssh-hosts-heading"
/>
<div className="ssh-hosts-toolbar">
{hosts.length > 0 && (
<label className="ssh-hosts-toolbar__search">
<Search aria-hidden="true" size={14} />
<input
aria-label={t('sshHosts.search.label')}
onChange={(event) => setQuery(event.target.value)}
placeholder={t('sshHosts.search.placeholder')}
type="search"
value={query}
/>
</label>
)}
<button
className="primary-button"
disabled={loading || Boolean(editingId) || !api}
onClick={() => {
setEditingId('new')
setError(undefined)
}}
type="button"
>
<Plus aria-hidden="true" size={14} />
{t('sshHosts.actions.add')}
</button>
</div>
{error && (
<p className="settings-warning" role="alert">
{error}
</p>
)}
<div className="settings-notice">
<ShieldCheck aria-hidden="true" size={16} />
@@ -676,12 +715,26 @@ export function SshHostsSettingsSection({
icon={<Server size={22} />}
title={t('sshHosts.empty.title')}
/>
) : visibleHosts.length === 0 ? (
<EmptyState
action={
<button
className="secondary-button"
onClick={() => setQuery('')}
type="button"
>
{t('sshHosts.search.clear')}
</button>
}
description={t('sshHosts.search.noResults')}
icon={<Search size={22} />}
/>
) : (
<div
aria-label={t('sshHosts.listLabel')}
className="ssh-hosts-list"
>
{hosts.map((host) => {
{visibleHosts.map((host) => {
const relatedProjects =
projectReferences?.[host.id] ?? []
const result = testResults[host.id]
@@ -722,20 +775,98 @@ export function SshHostsSettingsSection({
'function' &&
typeof api.onRemoteEnvironmentUpdateProgress ===
'function'
const environmentSummary = remoteEnvironment?.value
? environmentComponents.some(
({ state }) => state === 'not-installed'
)
? 'not-installed'
: environmentComponents.some(
({ state }) => state === 'update-available'
)
? 'update-available'
: 'current'
: 'unknown'
// Progress, failures, and removal confirmation must stay visible.
const forcedOpen =
Boolean(hostEnvironmentUpdate) ||
Boolean(remoteEnvironmentUpdateError) ||
Boolean(remoteEnvironment?.error) ||
confirmingDeleteId === host.id
const expanded =
forcedOpen ||
(expandedOverrides.get(host.id) ?? hosts.length === 1)
const detailsId = `ssh-host-details-${host.id}`
return (
<section
aria-label={host.name}
className="settings-section ssh-host-card"
className={`settings-section ssh-host-card${expanded ? ' ssh-host-card--expanded' : ''}`}
key={host.id}
>
<div className="settings-section__title settings-section__title--actions">
<Server aria-hidden="true" size={17} />
<div>
<strong>{host.name}</strong>
<small>
{host.username}@{host.hostname}:{host.port}
</small>
</div>
<div className="ssh-host-card__summary">
<button
aria-controls={detailsId}
aria-expanded={expanded}
aria-label={t(
expanded
? 'sshHosts.list.collapseNamed'
: 'sshHosts.list.expandNamed',
{ name: host.name }
)}
className="ssh-host-card__toggle"
disabled={forcedOpen}
onClick={() => setHostExpanded(host.id, !expanded)}
type="button"
>
<ChevronRight
aria-hidden="true"
className="ssh-host-card__chevron"
size={15}
/>
<Server aria-hidden="true" size={16} />
<span className="ssh-host-card__identity">
<strong>{host.name}</strong>
<small>
{host.username}@{host.hostname}:{host.port}
</small>
</span>
</button>
<span className="ssh-host-card__badges">
<span
className={`ssh-host-badge ssh-host-badge--${host.lastValidatedAt ? 'ok' : 'warn'}`}
>
{t(
host.lastValidatedAt
? 'sshHosts.list.validated'
: 'sshHosts.status.needsValidation'
)}
</span>
{host.lastValidatedAt && (
<span
className={`ssh-host-badge ssh-host-badge--${
hostEnvironmentUpdate
? 'busy'
: environmentSummary === 'current'
? 'ok'
: environmentSummary === 'unknown'
? 'muted'
: 'warn'
}`}
>
{t(
hostEnvironmentUpdate
? 'sshHosts.list.environmentUpdating'
: `sshHosts.list.environment.${environmentSummary}`
)}
</span>
)}
{relatedProjects.length > 0 && (
<span className="ssh-host-badge ssh-host-badge--muted">
{t('sshHosts.list.projects', {
count: relatedProjects.length
})}
</span>
)}
</span>
<button
aria-label={t(
host.lastValidatedAt
@@ -755,13 +886,57 @@ export function SshHostsSettingsSection({
}}
type="button"
>
<Pencil aria-hidden="true" size={13} />
{host.lastValidatedAt
? t('sshHosts.actions.edit')
: t('sshHosts.actions.validate')}
</button>
<DestructiveConfirmActions
confirmLabel={t(
'sshHosts.actions.confirmRemove'
)}
confirming={confirmingDeleteId === host.id}
disabled={
deleting || Boolean(hostEnvironmentUpdate)
}
message={
confirmingDeleteId === host.id ? (
<div className="ssh-host-remove-confirmation">
<p>
{t('sshHosts.removeMessage', {
name: host.name
})}
</p>
{relatedProjects.length > 0 && (
<>
<strong>
{t('sshHosts.removeProjectsHeading')}
</strong>
<ul>
{relatedProjects.map((project) => (
<li key={project.id}>
{project.name}
</li>
))}
</ul>
</>
)}
</div>
) : null
}
onCancel={() =>
setConfirmingDeleteId(undefined)
}
onConfirm={() => void remove(host)}
onRequestConfirm={() =>
setConfirmingDeleteId(host.id)
}
triggerAriaLabel={t('sshHosts.actions.removeNamed', { name: host.name })}
triggerLabel={t('sshHosts.actions.remove')}
/>
</div>
{expanded && (
<div className="ssh-host-card__details" id={detailsId}>
<dl className="ssh-host-card__facts">
<div>
<dt>{t('sshHosts.fields.authentication')}</dt>
@@ -1053,39 +1228,49 @@ export function SshHostsSettingsSection({
)}
{remoteEnvironment?.value ? (
<div className="ssh-host-environment__grid">
<EnvironmentVersionCard
icon={<Bot aria-hidden="true" size={17} />}
installed={
remoteEnvironment.value.agent.installed
}
label="GoodBuddy Agent"
state={remoteEnvironment.value.agent.state}
t={t}
expected={
remoteEnvironment.value.agent.expected
}
/>
{remoteEnvironment.value.runtimes.map(
(runtime) => (
<EnvironmentVersionCard
icon={
<Server aria-hidden="true" size={17} />
}
installed={runtime.installed}
key={runtime.runtimeId}
label={
runtime.runtimeId === 'opencode'
? 'OpenCode Runtime'
: runtime.runtimeId
}
state={runtime.state}
t={t}
expected={runtime.expected}
/>
)
)}
</div>
<table className="ssh-host-environment__table">
<thead>
<tr>
<th scope="col">{t('sshHosts.environment.component')}</th>
<th scope="col">{t('sshHosts.environment.installed')}</th>
<th scope="col">{t('sshHosts.environment.expected')}</th>
<th scope="col">{t('sshHosts.environment.status')}</th>
</tr>
</thead>
<tbody>
<EnvironmentVersionRow
icon={<Bot aria-hidden="true" size={15} />}
installed={
remoteEnvironment.value.agent.installed
}
label="GoodBuddy Agent"
state={remoteEnvironment.value.agent.state}
t={t}
expected={
remoteEnvironment.value.agent.expected
}
/>
{remoteEnvironment.value.runtimes.map(
(runtime) => (
<EnvironmentVersionRow
icon={
<Server aria-hidden="true" size={15} />
}
installed={runtime.installed}
key={runtime.runtimeId}
label={
runtime.runtimeId === 'opencode'
? 'OpenCode Runtime'
: runtime.runtimeId
}
state={runtime.state}
t={t}
expected={runtime.expected}
/>
)
)}
</tbody>
</table>
) : remoteEnvironment?.loading ? (
<div
className="ssh-host-environment__loading"
@@ -1106,50 +1291,8 @@ export function SshHostsSettingsSection({
</div>
)}
<div className="ssh-host-card__actions">
<DestructiveConfirmActions
confirmLabel={t(
'sshHosts.actions.confirmRemove'
)}
confirming={confirmingDeleteId === host.id}
disabled={
deleting || Boolean(hostEnvironmentUpdate)
}
message={
confirmingDeleteId === host.id ? (
<div className="ssh-host-remove-confirmation">
<p>
{t('sshHosts.removeMessage', {
name: host.name
})}
</p>
{relatedProjects.length > 0 && (
<>
<strong>
{t('sshHosts.removeProjectsHeading')}
</strong>
<ul>
{relatedProjects.map((project) => (
<li key={project.id}>
{project.name}
</li>
))}
</ul>
</>
)}
</div>
) : null
}
onCancel={() =>
setConfirmingDeleteId(undefined)
}
onConfirm={() => void remove(host)}
onRequestConfirm={() =>
setConfirmingDeleteId(host.id)
}
triggerLabel={t('sshHosts.actions.remove')}
/>
</div>
)}
</section>
)
})}
@@ -1198,7 +1341,7 @@ function formatPackageSize(bytes: number): string {
return `${mebibytes >= 10 ? Math.round(mebibytes) : mebibytes.toFixed(1)} MiB`
}
function EnvironmentVersionCard({
function EnvironmentVersionRow({
expected,
icon,
installed,
@@ -1216,48 +1359,44 @@ function EnvironmentVersionCard({
t: ReturnType<typeof useTranslation<'settingsSections'>>['t']
}): React.JSX.Element {
return (
<article className="ssh-host-environment__runtime">
<div className="ssh-host-environment__runtime-header">
<span className="ssh-host-environment__runtime-icon">
<tr>
<th scope="row">
<span className="ssh-host-environment__component">
{icon}
{label}
</span>
<strong>{label}</strong>
</th>
<td>
{installed ? (
<span
title={t('sshHosts.environment.versionDetail', {
architecture: installed.architecture
})}
>
{installed.version}
</span>
) : (
<span className="ssh-host-environment__missing">
{t('sshHosts.environment.notInstalled')}
</span>
)}
</td>
<td>
<span
title={t('sshHosts.environment.versionDetail', {
architecture: expected.architecture
})}
>
{expected.version}
</span>
</td>
<td>
<span
className={`ssh-host-environment__badge ssh-host-environment__badge--${state}`}
>
{t(`sshHosts.environment.states.${state}`)}
</span>
</div>
<dl>
<div>
<dt>{t('sshHosts.environment.installed')}</dt>
<dd>
{installed ? (
<>
<strong>{installed.version}</strong>
<small>
{t('sshHosts.environment.versionDetail', {
architecture: installed.architecture
})}
</small>
</>
) : (
<span>{t('sshHosts.environment.notInstalled')}</span>
)}
</dd>
</div>
<div>
<dt>{t('sshHosts.environment.expected')}</dt>
<dd>
<strong>{expected.version}</strong>
<small>
{t('sshHosts.environment.versionDetail', {
architecture: expected.architecture
})}
</small>
</dd>
</div>
</dl>
</article>
</td>
</tr>
)
}
+10
View File
@@ -132,6 +132,16 @@
.application-settings .toggle-row span {
font-weight: 500;
}
/* A switch with inline help keeps the help next to its text and the switch
on the right edge, aligned with the other switches. */
.application-settings .inline-help-label {
display: flex;
width: 100%;
min-height: 36px;
}
.application-settings .inline-help-label > .toggle-row {
display: contents;
}
.application-settings .settings-notice {
margin: 0 0 var(--space-2);
font-size: var(--font-body);
@@ -35,12 +35,27 @@ export const app = {
enabled: 'Enabled', disabled: 'Disabled', open: 'Open', moveUp: 'Move {{name}} up', moveDown: 'Move {{name}} down',
orderUpdated: 'Application order updated', loadFailed: 'Could not load application settings', saveFailed: 'Could not save application settings',
confirmFailed: 'Could not confirm the save result. Reload settings before editing again.',
remoteHosts: 'Hosts',
remoteHostsEnable: 'Enable remote capability',
remoteHostsHelp: 'Remote capability is off by default. Enable it to add SSH hosts, install execution environments, and create remote projects. Turning it off hides remote projects and stops remote execution; hosts, credentials, remote projects, and Agent packages are kept. Agent packages remain manageable while it is off.',
descriptions: {
'device-sharing': 'Test feature, off by default. Register devices, publish and revoke capability or knowledge metadata.',
'remote-hosts': 'Connect SSH hosts, install remote execution environments, and manage Agent packages.',
'magic-notes': 'Write notes, organize to-dos, and receive AI comments.', knowledge: 'Manage knowledge sources and retrieve reference material for conversations.',
heartbeat: 'Review work evolution, explore the story graph, and configure Smart heartbeat.', 'local-inference': 'Inspect local inference tasks and execution services.'
}
},
remoteHosts: {
title: 'Hosts',
description: 'Connect SSH hosts, install remote execution environments, and manage Agent packages. Local Skills and MCP servers are not applied to remote hosts.',
tabsLabel: 'Hosts',
tabs: { hosts: 'Hosts', packages: 'Agent packages' },
disabled: {
title: 'Remote capability is off',
description: 'Enable it to add SSH hosts, install execution environments, and create remote projects. Agent packages can be downloaded or imported ahead of time while it is off.',
enable: 'Enable remote capability'
}
},
notifications: {
success: 'Success',
error: 'Error',
@@ -12,6 +12,25 @@ export const settingsSections = {
loading: 'Loading SSH hosts…',
loadFailedTitle: 'Could not load SSH hosts',
listLabel: 'SSH host list',
list: {
expandNamed: 'Show details for {{name}}',
collapseNamed: 'Hide details for {{name}}',
validated: 'Validated',
environmentUpdating: 'Installing',
environment: {
current: 'Environment ready',
'update-available': 'Update available',
'not-installed': 'Not installed',
unknown: 'Not checked'
},
projects: '{{count}} projects'
},
search: {
label: 'Search hosts',
placeholder: 'Search by name or address',
noResults: 'No matching hosts',
clear: 'Clear search'
},
securityNotice:
'When a host is added or edited, GoodBuddy inspects its host key before sending credentials and running a bounded system probe. The host and encrypted password are saved only after every step succeeds.',
secureStorageUnavailable:
@@ -137,6 +156,8 @@ export const settingsSections = {
reinstallFailedSummary:
'This reinstall did not complete. Checking the current versions again.'
},
component: 'Component',
status: 'Status',
installed: 'Installed on Host',
expected: 'Required by GoodBuddy',
notInstalled: 'Not installed',
@@ -200,6 +221,7 @@ export const settingsSections = {
validateNamed: 'Validate {{name}}',
inspecting: 'Inspecting…',
remove: 'Delete',
removeNamed: 'Delete {{name}}',
confirmRemove: 'Confirm delete',
cancel: 'Cancel',
closeDialog: 'Close SSH host validation',
@@ -147,17 +147,17 @@ export const workspace = {
loadingHosts: 'Loading saved hosts…',
noHosts: 'No saved SSH hosts',
hostHelp:
'Host credentials remain centrally managed in Settings and are not copied into the project.',
'Host credentials remain centrally managed in Apps > Hosts and are not copied into the project.',
readiness: {
loading: 'Reading the Host validation record…',
ready:
'This Host is validated. Saving connects and checks the Agent, workspace, and Runtime.',
unready:
'This Host has not completed Host Key and connection validation. Validate it under Settings > SSH Hosts first.',
'This Host has not completed Host Key and connection validation. Validate it under Apps > Hosts first.',
error:
'Could not read this Host’s local validation record. Check it under Settings > SSH Hosts.',
'Could not read this Host’s local validation record. Check it under Apps > Hosts.',
saveBlocked:
'The selected Host has not been validated. Validate its connection under Settings > SSH Hosts first.',
'The selected Host has not been validated. Validate its connection under Apps > Hosts first.',
options: {
loading: 'Reading',
ready: 'Validated',
@@ -32,12 +32,27 @@ export const app = {
enabled: '已启用', disabled: '已关闭', open: '打开', moveUp: '上移 {{name}}', moveDown: '下移 {{name}}',
orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败',
confirmFailed: '无法确认保存结果,请重新读取后再修改。',
remoteHosts: '主机管理',
remoteHostsEnable: '启用远程能力',
remoteHostsHelp: '远程能力默认关闭。启用后可以添加 SSH 主机、安装执行环境并创建远程项目。关闭后远程项目从项目列表中隐藏,也不能再在远端执行;主机、凭据、远程项目和 Agent 离线包都会保留。Agent 离线包在关闭时仍可管理。',
descriptions: {
'device-sharing': '测试功能,默认关闭。注册设备,发布和撤销能力或知识的元数据。',
'remote-hosts': '接入 SSH 主机,安装远端执行环境,管理 Agent 离线包。',
'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。',
heartbeat: '回顾工作演变、查看故事线图谱,并配置智能心跳。', 'local-inference': '查看本机推理任务与执行服务。'
}
},
remoteHosts: {
title: '主机管理',
description: '接入 SSH 主机,安装远端执行环境,管理 Agent 离线包。本机配置的 Skills 和 MCP 不会应用到远程主机。',
tabsLabel: '主机管理',
tabs: { hosts: '主机', packages: 'Agent 离线包' },
disabled: {
title: '远程能力未启用',
description: '启用后可以添加 SSH 主机、安装执行环境并创建远程项目。Agent 离线包在未启用时也可以提前下载或导入。',
enable: '启用远程能力'
}
},
notifications: {
success: '成功',
error: '错误',
@@ -7,6 +7,25 @@ export const settingsSections = {
loading: '正在读取 SSH 主机…',
loadFailedTitle: 'SSH 主机加载失败',
listLabel: 'SSH 主机列表',
list: {
expandNamed: '展开 {{name}} 的详情',
collapseNamed: '收起 {{name}} 的详情',
validated: '已验证',
environmentUpdating: '安装中',
environment: {
current: '环境就绪',
'update-available': '环境待更新',
'not-installed': '环境未安装',
unknown: '环境未检查'
},
projects: '{{count}} 个项目'
},
search: {
label: '搜索主机',
placeholder: '搜索名称或地址',
noResults: '没有匹配的主机',
clear: '清除搜索'
},
securityNotice:
'添加或编辑主机时,GoodBuddy 会先检查 Host Key,再发送认证凭据并执行有界系统探测。只有全部成功后才会保存主机和加密密码。',
secureStorageUnavailable:
@@ -129,6 +148,8 @@ export const settingsSections = {
reinstallFailedSummary:
'本次重新安装未完成;正在重新检查当前版本。'
},
component: '组件',
status: '状态',
installed: 'Host 已安装',
expected: 'GoodBuddy 所需',
notInstalled: '尚未安装',
@@ -192,6 +213,7 @@ export const settingsSections = {
validateNamed: '验证 {{name}}',
inspecting: '正在检查…',
remove: '删除',
removeNamed: '删除 {{name}}',
confirmRemove: '确认删除',
cancel: '取消',
closeDialog: '关闭 SSH 主机验证',
@@ -143,17 +143,17 @@ export const workspace = {
},
loadingHosts: '正在读取已保存主机…',
noHosts: '没有已保存的 SSH 主机',
hostHelp: '主机凭据由设置中心统一管理,不会复制到项目中。',
hostHelp: '主机凭据在“应用 > 主机管理”中统一管理,不会复制到项目中。',
readiness: {
loading: '正在读取主机验证记录…',
ready:
'此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。',
unready:
'此主机尚未完成 Host Key 和连接验证。请先前往“设置 > SSH 主机”完成验证。',
'此主机尚未完成 Host Key 和连接验证。请先前往“应用 > 主机管理”完成验证。',
error:
'无法读取此主机的本地验证记录。请前往“设置 > SSH 主机”检查配置。',
'无法读取此主机的本地验证记录。请前往“应用 > 主机管理”检查配置。',
saveBlocked:
'所选主机尚未完成验证。请先在“设置 > SSH 主机”验证连接。',
'所选主机尚未完成验证。请先在“应用 > 主机管理”验证连接。',
options: {
loading: '读取中',
ready: '已验证',
+260
View File
@@ -0,0 +1,260 @@
.remote-hosts-tabs {
margin-block: var(--space-4) var(--space-5);
}
.remote-hosts-panel {
display: grid;
min-width: 0;
gap: var(--space-4);
}
.remote-hosts-panel[hidden] {
display: none;
}
.ssh-hosts-toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: flex-end;
gap: var(--space-2);
}
.ssh-hosts-toolbar__search {
display: flex;
flex: 1 1 240px;
max-width: 360px;
align-items: center;
gap: var(--space-2);
margin-right: auto;
padding: 0 var(--space-3);
border: 1px solid var(--border-control);
border-radius: var(--radius-control, 6px);
background: var(--surface-raised);
color: var(--text-muted);
}
.ssh-hosts-toolbar__search:focus-within {
border-color: var(--accent);
outline: 2px solid var(--accent-selected);
outline-offset: 0;
}
.ssh-hosts-toolbar__search input {
flex: 1;
min-width: 0;
min-height: 32px;
border: 0;
background: transparent;
color: var(--text-primary);
font: inherit;
outline: none;
}
.ssh-hosts-list {
gap: var(--space-2);
}
.ssh-host-card {
gap: 0;
padding: var(--space-2) var(--space-3);
transition: border-color 120ms ease;
}
.ssh-host-card:hover,
.ssh-host-card--expanded {
border-color: var(--border-default);
}
.ssh-host-card__summary {
display: flex;
min-width: 0;
align-items: center;
gap: var(--space-3);
}
.ssh-host-card__toggle {
display: flex;
flex: 1 1 auto;
min-width: 0;
align-items: center;
gap: var(--space-2);
padding: var(--space-1) 0;
border: 0;
background: transparent;
color: var(--text-secondary);
font: inherit;
text-align: left;
cursor: pointer;
}
.ssh-host-card__toggle:disabled {
cursor: default;
}
.ssh-host-card__toggle:focus-visible {
border-radius: var(--radius-control);
outline: 2px solid var(--accent);
outline-offset: 2px;
}
.ssh-host-card__chevron {
flex: none;
transition: transform 120ms ease;
}
.ssh-host-card--expanded .ssh-host-card__chevron {
transform: rotate(90deg);
}
.ssh-host-card__identity {
display: flex;
min-width: 0;
align-items: baseline;
gap: var(--space-3);
}
.ssh-host-card__identity strong {
overflow: hidden;
color: var(--text-primary);
text-overflow: ellipsis;
white-space: nowrap;
}
.ssh-host-card__identity small {
overflow: hidden;
color: var(--text-muted);
font-family: var(--font-mono, ui-monospace, Consolas, monospace);
font-size: var(--font-caption);
text-overflow: ellipsis;
white-space: nowrap;
}
.ssh-host-card__badges {
display: flex;
flex: none;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-1);
}
.ssh-host-badge {
padding: 1px 8px;
border-radius: 999px;
font-size: var(--font-caption);
line-height: 1.6;
white-space: nowrap;
}
.ssh-host-badge--ok {
background: var(--success-subtle);
color: var(--success);
}
.ssh-host-badge--warn {
background: var(--warning-subtle);
color: var(--warning);
}
.ssh-host-badge--busy {
background: var(--accent-subtle);
color: var(--accent);
}
.ssh-host-badge--muted {
background: var(--surface-muted);
color: var(--text-secondary);
}
.ssh-host-card__summary {
flex-wrap: wrap;
}
.ssh-host-card__summary > .secondary-button,
.ssh-host-card__summary > .danger-button {
flex: none;
}
/* The removal confirmation takes the full width under the summary. */
.ssh-host-card__summary > .danger-confirm {
flex: 1 0 100%;
margin-top: var(--space-2);
}
.ssh-host-environment__table {
width: 100%;
border-collapse: collapse;
font-size: var(--font-body);
}
.ssh-host-environment__table th,
.ssh-host-environment__table td {
padding: var(--space-2) var(--space-3);
border-bottom: 1px solid var(--border-subtle);
text-align: left;
vertical-align: middle;
}
.ssh-host-environment__table thead th {
color: var(--text-muted);
font-size: var(--font-caption);
font-weight: 500;
}
.ssh-host-environment__table tbody tr:last-child > * {
border-bottom: 0;
}
.ssh-host-environment__table tbody th {
color: var(--text-primary);
font-weight: 500;
}
.ssh-host-environment__table td {
font-variant-numeric: tabular-nums;
}
.ssh-host-environment__component {
display: inline-flex;
align-items: center;
gap: var(--space-2);
}
.ssh-host-environment__component svg {
color: var(--accent);
}
.ssh-host-environment__missing {
color: var(--text-muted);
}
.ssh-host-card__details {
display: flex;
flex-direction: column;
gap: var(--space-4);
margin-top: var(--space-2);
padding-top: var(--space-3);
border-top: 1px solid var(--border-subtle);
}
@container (max-width: 760px) {
.ssh-host-card__summary {
flex-wrap: wrap;
}
.ssh-host-card__identity {
flex-direction: column;
gap: 0;
}
.ssh-host-card__badges {
justify-content: flex-start;
padding-left: calc(15px + 16px + var(--space-2) * 2);
}
}
@container (max-width: 640px) {
.remote-hosts-panel .ssh-host-card__facts {
grid-template-columns: minmax(0, 1fr);
}
}
+3 -9
View File
@@ -4,19 +4,13 @@ import { getSettingsCategoryList } from './settings-categories'
describe('getSettingsCategoryList', () => {
it('defines an icon for every settings category', () => {
expect(
getSettingsCategoryList(true).every(({ icon }) => icon != null)
getSettingsCategoryList().every(({ icon }) => icon != null)
).toBe(true)
})
it('hides SSH hosts while Remote Projects is disabled', () => {
it('no longer lists SSH hosts, which moved to the Hosts application', () => {
expect(
getSettingsCategoryList(false).map(({ id }) => id)
getSettingsCategoryList().map(({ id }) => id as string)
).not.toContain('ssh-hosts')
})
it('includes SSH hosts while Remote Projects is enabled', () => {
expect(
getSettingsCategoryList(true).map(({ id }) => id)
).toContain('ssh-hosts')
})
})
+5 -13
View File
@@ -6,7 +6,7 @@ import {
Info,
Palette,
Radio,
Server,
SlidersHorizontal,
Terminal,
Users,
@@ -44,12 +44,7 @@ export const settingsCategoryList = [
translationKey: 'runtime',
icon: Terminal
},
{
id: 'ssh-hosts',
group: 'execution',
translationKey: 'sshHosts',
icon: Server
},
{
id: 'document-parsing',
group: 'extensions',
@@ -86,12 +81,9 @@ export type SettingsCategoryDefinition =
(typeof settingsCategoryList)[number]
export type SettingsCategoryId = SettingsCategoryDefinition['id']
export function getSettingsCategoryList(
remoteProjectsEnabled: boolean
): readonly SettingsCategoryDefinition[] {
return remoteProjectsEnabled
? settingsCategoryList
: settingsCategoryList.filter(({ id }) => id !== 'ssh-hosts')
// SSH hosts moved to the Hosts application and are no longer a settings category.
export function getSettingsCategoryList(): readonly SettingsCategoryDefinition[] {
return settingsCategoryList
}
type LocalizedSettingsCategoryDefinition = SettingsCategoryDefinition & {
+7 -5
View File
@@ -37,13 +37,13 @@ export const defaultSupervisorModelConcurrency = 1
export const supervisorModelConcurrencySchema = z.number().int().min(1).max(4)
export const supervisionTimeoutSecondsSchema = z.number().int().min(30).max(600)
export const builtInApplicationIds = ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing'] as const
export const builtInApplicationIds = ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing', 'remote-hosts'] as const
export type BuiltInApplicationId = typeof builtInApplicationIds[number]
export const editableApplicationIds = ['magic-notes', 'heartbeat', 'local-inference', 'device-sharing'] as const
export const editableApplicationIds = ['magic-notes', 'heartbeat', 'local-inference', 'device-sharing', 'remote-hosts'] as const
export type EditableApplicationId = typeof editableApplicationIds[number]
export const defaultApplicationNavigation = {
order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference', 'device-sharing'] as BuiltInApplicationId[],
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': false }
order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference', 'device-sharing', 'remote-hosts'] as BuiltInApplicationId[],
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': false, 'remote-hosts': false }
}
export const applicationNavigationSchema = z.object({
order: z.array(z.enum(builtInApplicationIds)).length(builtInApplicationIds.length)
@@ -51,7 +51,9 @@ export const applicationNavigationSchema = z.object({
pinned: z.object({
'magic-notes': z.boolean(),
heartbeat: z.boolean().default(true),
'local-inference': z.boolean()
'local-inference': z.boolean(),
// Optional on input so callers written before the application existed keep working.
'remote-hosts': z.boolean().default(false)
}).strict()
}).strict()
@@ -23,10 +23,14 @@ describe('GoodBuddy configuration contracts', () => {
operation: 'application.update',
updates: { applicationNavigation: {
...defaultApplicationNavigation,
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'],
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing', 'remote-hosts'],
} },
}
expect(goodbuddyConfigOperationSchema.parse(operation)).toEqual(operation)
// The order written before Hosts existed is no longer complete.
expect(goodbuddyConfigOperationSchema.safeParse({ ...operation, updates: {
applicationNavigation: { ...defaultApplicationNavigation, order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'] },
} }).success).toBe(false)
for (const order of [['magic-notes', 'local-inference'], ['knowledge', 'heartbeat', 'magic-notes', 'magic-notes'], ['knowledge', 'heartbeat', 'magic-notes', 'invalid']]) {
expect(goodbuddyConfigOperationSchema.safeParse({ ...operation, updates: {
applicationNavigation: { ...defaultApplicationNavigation, order },