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() const settings = await store.get()
expect(settings.warnings).toBeUndefined() expect(settings.warnings).toBeUndefined()
expect(settings.magicNotesEnabled).toBe(false) 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) expect(applicationSettingsSchema.parse(settings)).toEqual(settings)
await store.update({ checkUpdatesOnStartup: false }) await store.update({ checkUpdatesOnStartup: false })
expect(JSON.parse(await readFile(filePath, 'utf8')).applicationNavigation).toEqual(settings.applicationNavigation) 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 () => { it('validates full-order writes, persists and publishes them, and rejects partial writes without mutation', async () => {
const { store, filePath } = await createStore() const { store, filePath } = await createStore()
const applicationNavigation = { const applicationNavigation = {
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'], order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'remote-hosts', 'device-sharing'],
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false }, pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': true },
} }
const changed = vi.fn() const changed = vi.fn()
store.onChanged(changed) store.onChanged(changed)
@@ -337,11 +337,27 @@ describe('ApplicationSettingsStore', () => {
pinned: { 'magic-notes': false, 'local-inference': pinned } pinned: { 'magic-notes': false, 'local-inference': pinned }
} }
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, applicationNavigation, version: 11, lastSeenReleaseNotesVersion: null }), 'utf8') 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) expect((await store.get()).applicationNavigation).toEqual(normalized)
await store.update({ checkUpdatesOnStartup: false }) await store.update({ checkUpdatesOnStartup: false })
expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(normalized) 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 () => { it('migrates released settings without replacing explicit false values or comment choices', async () => {
const { filePath, store } = await createStore() 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' } 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), ).toHaveTextContent(remoteProject.name),
); );
fireEvent.click(screen.getByRole('button', { name: '设置' })); // The switch now lives in the Hosts application settings.
fireEvent.click(await screen.findByRole("tab", { name: "平台功能" })); fireEvent.click(screen.getByRole("button", { name: "应用" }));
fireEvent.click(await screen.findByRole("menuitem", { name: "管理应用" }));
fireEvent.click( fireEvent.click(
await screen.findByRole("tab", { await screen.findByRole("button", { name: "主机管理 应用设置" }),
name: "远程项目",
}),
); );
const remoteProjectsSwitch = await screen.findByRole("switch", { const remoteProjectsSwitch = await screen.findByRole("switch", {
name: "远程项目", name: "启用远程能力",
}); });
expect(remoteProjectsSwitch).toBeChecked(); expect(remoteProjectsSwitch).toBeChecked();
fireEvent.click(remoteProjectsSwitch); fireEvent.click(remoteProjectsSwitch);
@@ -15024,7 +15023,7 @@ describe("App", () => {
); );
await screen.findByRole("heading", { name: "设置中心" }); await screen.findByRole("heading", { name: "设置中心" });
fireEvent.click(screen.getByRole("tab", { 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("tab", { name: "魔法笔记" })).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "应用设置" })).not.toBeInTheDocument(); expect(screen.queryByRole("button", { name: "应用设置" })).not.toBeInTheDocument();
expect(api.updates.updateSettings).not.toHaveBeenCalled(); expect(api.updates.updateSettings).not.toHaveBeenCalled();
@@ -15073,24 +15072,24 @@ describe("App", () => {
await waitFor(() => expect(move).toBeEnabled()) await waitFor(() => expect(move).toBeEnabled())
fireEvent.click(move) fireEvent.click(move)
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([ 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()) await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled())
const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article') const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article')
fireEvent.dragStart(cards[3]!) fireEvent.dragStart(cards[3]!)
fireEvent.drop(cards[1]!) fireEvent.drop(cards[1]!)
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([ 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: '关闭应用中心' })) fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([ expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([
'对话', '监督者', '知识库', '魔法笔记', '运行记录', '对话', '监督者', '知识库', '魔法笔记', '运行记录',
]) ])
fireEvent.click(screen.getByRole('button', { name: '应用' })) 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: '管理应用' })) 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 () => { 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, ApplicationCenter,
applicationDefinitions, applicationDefinitions,
isApplicationEnabled, isApplicationEnabled,
isApplicationOpenable,
} from "./ApplicationCenter"; } from "./ApplicationCenter";
import { import {
defaultApplicationNavigation, defaultApplicationNavigation,
@@ -287,6 +288,10 @@ const activityPanelRoute = createPreloadableComponent(
() => import("./ActivityPanel"), () => import("./ActivityPanel"),
(module) => module.ActivityPanel, (module) => module.ActivityPanel,
); );
const remoteHostsPageRoute = createPreloadableComponent(
() => import("./RemoteHostsPage"),
(module) => module.RemoteHostsPage,
);
const idleRouteModuleLoaders = [heartbeatCenterRoute.preload] as const; const idleRouteModuleLoaders = [heartbeatCenterRoute.preload] as const;
const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component; const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component;
@@ -294,6 +299,7 @@ const HeartbeatCenter = heartbeatCenterRoute.Component;
const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component; const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component;
const SettingsPanel = settingsPanelRoute.Component; const SettingsPanel = settingsPanelRoute.Component;
const ActivityPanel = activityPanelRoute.Component; const ActivityPanel = activityPanelRoute.Component;
const RemoteHostsPage = remoteHostsPageRoute.Component;
const conversationPersistenceIntervalMs = 500; const conversationPersistenceIntervalMs = 500;
// How often streaming deltas are absorbed into the conversation store; rows render them live. // How often streaming deltas are absorbed into the conversation store; rows render them live.
@@ -507,14 +513,14 @@ export function AppNotificationViewport({
} }
type WorkspaceView = 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 // 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. // only in event handlers and the sweep timer, so this is not React state.
function createUnsavedWorkspaceTracker(): { function createUnsavedWorkspaceTracker(): {
pinnedViews: () => Set<WorkspaceView>; pinnedViews: () => Set<WorkspaceView>;
reporters: Record< reporters: Record<
"knowledge" | "heartbeat" | "magic-notes" | "activity", "knowledge" | "heartbeat" | "magic-notes" | "remote-hosts" | "activity",
ReportWorkspaceUnsavedChanges ReportWorkspaceUnsavedChanges
>; >;
} { } {
@@ -535,6 +541,7 @@ function createUnsavedWorkspaceTracker(): {
knowledge: reporterFor("knowledge"), knowledge: reporterFor("knowledge"),
heartbeat: reporterFor("heartbeat"), heartbeat: reporterFor("heartbeat"),
"magic-notes": reporterFor("magic-notes"), "magic-notes": reporterFor("magic-notes"),
"remote-hosts": reporterFor("remote-hosts"),
activity: reporterFor("activity"), activity: reporterFor("activity"),
}, },
}; };
@@ -547,6 +554,7 @@ const intentRoutePreloaders: Partial<
knowledge: knowledgeWorkspaceRoute.preload, knowledge: knowledgeWorkspaceRoute.preload,
activity: activityPanelRoute.preload, activity: activityPanelRoute.preload,
settings: settingsPanelRoute.preload, settings: settingsPanelRoute.preload,
"remote-hosts": remoteHostsPageRoute.preload,
}; };
function preloadWorkspaceRouteOnIntent(view: WorkspaceView): void { function preloadWorkspaceRouteOnIntent(view: WorkspaceView): void {
@@ -1515,6 +1523,9 @@ function App(): React.JSX.Element {
setActiveIdState(next); setActiveIdState(next);
}, [activeConversationViewIds, conversationStore]); }, [activeConversationViewIds, conversationStore]);
const [remoteProjectsEnabled, setRemoteProjectsEnabled] = useState(false); 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 [ const [
conversationHtmlRenderingEnabled, conversationHtmlRenderingEnabled,
setConversationHtmlRenderingEnabled, setConversationHtmlRenderingEnabled,
@@ -1527,7 +1538,7 @@ function App(): React.JSX.Element {
const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = useState(0); const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = useState(0);
const applicationNavigation = applicationSettings?.applicationNavigation ?? defaultApplicationNavigation; const applicationNavigation = applicationSettings?.applicationNavigation ?? defaultApplicationNavigation;
const visibleApplications = useMemo(() => applicationNavigation.order.filter(id => 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]); ), [applicationNavigation, applicationSettings, magicNotesEnabled]);
const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => { const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => {
setApplicationSettings(settings); setApplicationSettings(settings);
@@ -1549,7 +1560,11 @@ function App(): React.JSX.Element {
} }
setMagicNotesShowIncompleteTodoCount(settings.magicNotesShowIncompleteTodoCount); setMagicNotesShowIncompleteTodoCount(settings.magicNotesShowIncompleteTodoCount);
setConversationHtmlRenderingEnabled(settings.conversationHtmlRenderingEnabled !== false); 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]); }, [guardNoteDraft, setNoteDraft, setView]);
const reloadApplicationSettings = useCallback(async (): Promise<void> => { const reloadApplicationSettings = useCallback(async (): Promise<void> => {
const revision = ++applicationSettingsRevisionRef.current; const revision = ++applicationSettingsRevisionRef.current;
@@ -4310,9 +4325,15 @@ function App(): React.JSX.Element {
} }
const localProject = projects.find(isOrdinaryLocalProject); const localProject = projects.find(isOrdinaryLocalProject);
if (localProject) { if (localProject) {
// Keep the Hosts page open when the switch is turned off there.
const currentView = viewRef.current;
commitProjectSelection(localProject); commitProjectSelection(localProject);
if (currentView === "remote-hosts") commitView("remote-hosts");
} }
}; };
useLayoutEffect(() => {
remoteProjectsEnabledChangeRef.current = handleRemoteProjectsEnabledChange;
});
const archiveProject = async (projectId: string): Promise<void> => { const archiveProject = async (projectId: string): Promise<void> => {
await window.goodbuddy.projects.setArchived(projectId, true); await window.goodbuddy.projects.setArchived(projectId, true);
@@ -6535,13 +6556,14 @@ function App(): React.JSX.Element {
onExpertsChanged: (experts: AssistantExpert[]) => { onExpertsChanged: (experts: AssistantExpert[]) => {
setAssistantExperts(experts); setAssistantExperts(experts);
}, },
onRemoteProjectsEnabledChange: handleRemoteProjectsEnabledChange,
onProjectsDeleted: removeProjectsFromUi,
onSaved: (settings: RuntimeSettings) => { onSaved: (settings: RuntimeSettings) => {
setRuntimeSettings(settings); setRuntimeSettings(settings);
}, },
onUpdateProject: updateProject, onUpdateProject: updateProject,
}); });
const remoteHostsActions = useStableHandlers({
onProjectsDeleted: removeProjectsFromUi,
});
const rightSidebarActions = useStableHandlers<RightSidebarHandlers>({ const rightSidebarActions = useStableHandlers<RightSidebarHandlers>({
onBeforeCloseNotes: async () => { onBeforeCloseNotes: async () => {
if (!await guardNoteDraft()) return false; if (!await guardNoteDraft()) return false;
@@ -7339,6 +7361,38 @@ function App(): React.JSX.Element {
</KeepAliveRoute> </KeepAliveRoute>
)} )}
{view === 'device-sharing' && isApplicationEnabled(applicationSettings, 'device-sharing') && <DeviceSharingPage notify={notify} />} {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 && ( {localInferenceOpen && (
<LocalInferencePage <LocalInferencePage
enabled={isApplicationEnabled(applicationSettings, "local-inference")} enabled={isApplicationEnabled(applicationSettings, "local-inference")}
@@ -7356,7 +7410,7 @@ function App(): React.JSX.Element {
error={applicationSettingsError} error={applicationSettingsError}
onClose={() => setApplicationCenterOpen(false)} onClose={() => setApplicationCenterOpen(false)}
onOpen={(id) => { onOpen={(id) => {
if (!isApplicationEnabled(applicationSettings, id)) return; if (!isApplicationOpenable(applicationSettings, id)) return;
setApplicationCenterOpen(false); setApplicationCenterOpen(false);
setView(id); setView(id);
if (window.innerWidth < 900) setSidebarOpen(false); if (window.innerWidth < 900) setSidebarOpen(false);
@@ -7410,7 +7464,6 @@ function App(): React.JSX.Element {
initialCategory={settingsInitialCategory} initialCategory={settingsInitialCategory}
initialChannel={settingsInitialChannel} initialChannel={settingsInitialChannel}
magicNotesEnabled={magicNotesEnabled} magicNotesEnabled={magicNotesEnabled}
remoteProjectsEnabled={remoteProjectsEnabled}
onConversationHtmlRenderingEnabledChange={ onConversationHtmlRenderingEnabledChange={
setConversationHtmlRenderingEnabled setConversationHtmlRenderingEnabled
} }
+44 -14
View File
@@ -17,6 +17,7 @@ import {
ApplicationCenter, ApplicationCenter,
ApplicationSettingsView, ApplicationSettingsView,
isApplicationEnabled, isApplicationEnabled,
isApplicationOpenable,
} from './ApplicationCenter' } from './ApplicationCenter'
const settings = applicationSettingsSchema.parse({ const settings = applicationSettingsSchema.parse({
@@ -39,7 +40,7 @@ const pinnedSettings = {
...settings, ...settings,
applicationNavigation: { applicationNavigation: {
...defaultApplicationNavigation, ...defaultApplicationNavigation,
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': true }, pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': true, 'remote-hosts': false },
}, },
} }
afterEach(async () => { afterEach(async () => {
@@ -73,6 +74,35 @@ describe('Application Center', () => {
expect(handlers.onOpen).toHaveBeenCalledWith('device-sharing') 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) => { it.each(['zh-CN', 'en-US'] as const)('keeps disable consequences visible and pin help separate in %s', async (locale) => {
await changeUiLocale(locale) await changeUiLocale(locale)
const onUpdate = vi.fn(async () => true) const onUpdate = vi.fn(async () => true)
@@ -127,7 +157,7 @@ describe('Application Center', () => {
fireEvent.drop(card) fireEvent.drop(card)
expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: { expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: {
...defaultApplicationNavigation, ...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.dragStart(notes)
fireEvent.drop(inference) fireEvent.drop(inference)
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: { 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: '打开' })) fireEvent.click(within(inference).getByRole('button', { name: '打开' }))
expect(handlers.onOpen).not.toHaveBeenCalled() expect(handlers.onOpen).not.toHaveBeenCalled()
@@ -263,9 +293,9 @@ describe('Application Center', () => {
const handlers = props() const handlers = props()
const { rerender } = render(<ApplicationCenter {...handlers} settings={pinnedSettings} />) const { rerender } = render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
const displayedOrder = () => screen.getAllByRole('article').map((row) => row.querySelector('strong')?.textContent) 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() expect(screen.getByRole('button', { name: '下移 主机管理' })).toBeDisabled()
const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' }) const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' })
expect(moveUp.closest('article')).not.toBeNull() expect(moveUp.closest('article')).not.toBeNull()
expect(moveUp).toHaveAttribute('title', '上移 本机推理监控') expect(moveUp).toHaveAttribute('title', '上移 本机推理监控')
@@ -273,18 +303,18 @@ describe('Application Center', () => {
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ expect(handlers.onUpdate).toHaveBeenLastCalledWith({
applicationNavigation: { applicationNavigation: {
...pinnedSettings.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(await screen.findByRole('status')).not.toBeEmptyDOMElement()
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)']) expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)', '主机管理'])
rerender(<ApplicationCenter {...handlers} settings={{ rerender(<ApplicationCenter {...handlers} settings={{
...pinnedSettings, ...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(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享(技术预览)', '主机管理'])
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled() expect(screen.getByRole('button', { name: '下移 主机管理' })).toBeDisabled()
fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' })) fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' }))
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation }) expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation })
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' })) fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
@@ -306,7 +336,7 @@ describe('Application Center', () => {
expect(handlers.onUpdate).toHaveBeenCalledWith({ expect(handlers.onUpdate).toHaveBeenCalledWith({
applicationNavigation: { applicationNavigation: {
...pinnedSettings.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({ expect(handlers.onUpdate).toHaveBeenCalledWith({
applicationNavigation: { applicationNavigation: {
...pinnedSettings.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() expect(screen.getByText('未找到相关应用')).toBeInTheDocument()
fireEvent.click(screen.getByRole('button', { name: '清除搜索' })) 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', () => { it('edits all notes preferences through the shared settings form', () => {
+22 -3
View File
@@ -14,6 +14,7 @@ import {
Network, Network,
HeartPulse, HeartPulse,
Library, Library,
Server,
Settings, Settings,
Sparkles, Sparkles,
X, X,
@@ -52,6 +53,12 @@ export const applicationDefinitions = {
title: 'applications.localInference', title: 'applications.localInference',
enabled: 'localInferenceEnabled', enabled: 'localInferenceEnabled',
}, },
// Shares the remote projects switch: the backend gates hosts on it.
'remote-hosts': {
icon: Server,
title: 'applications.remoteHosts',
enabled: 'remoteProjectsEnabled',
},
} as const } as const
export function isApplicationEnabled( export function isApplicationEnabled(
@@ -60,6 +67,7 @@ export function isApplicationEnabled(
): boolean { ): boolean {
if (id === 'knowledge') return true if (id === 'knowledge') return true
if (id === 'device-sharing') return settings?.deviceSharingEnabled === true if (id === 'device-sharing') return settings?.deviceSharingEnabled === true
if (id === 'remote-hosts') return settings?.remoteProjectsEnabled === true
if (id === 'heartbeat') return settings?.heartbeatEnabled === true if (id === 'heartbeat') return settings?.heartbeatEnabled === true
return ( return (
settings !== undefined && 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({ export function ApplicationAvailability({
enabled, enabled,
children, children,
@@ -120,7 +139,7 @@ export function ApplicationSettingsView({
}) })
} }
/> />
<span>{t('applications.enable')}</span> <span>{t(id === 'remote-hosts' ? 'applications.remoteHostsEnable' : 'applications.enable')}</span>
</label> </label>
{id !== 'device-sharing' && <div className="inline-help-label"> {id !== 'device-sharing' && <div className="inline-help-label">
<label className="toggle-row"> <label className="toggle-row">
@@ -143,7 +162,7 @@ export function ApplicationSettingsView({
</label> </label>
<InlineHelp label={t('applications.pin')} id="application-pin-help">{t('applications.pinHelp')}</InlineHelp> <InlineHelp label={t('applications.pin')} id="application-pin-help">{t('applications.pinHelp')}</InlineHelp>
</div>} </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' && ( {id === 'magic-notes' && (
<label className="toggle-row"> <label className="toggle-row">
<input <input
@@ -464,7 +483,7 @@ export function ApplicationCenter({
<button <button
className="secondary-button" className="secondary-button"
type="button" type="button"
disabled={!enabled} disabled={!isApplicationOpenable(settings, id)}
onClick={() => onOpen(id)} onClick={() => onOpen(id)}
> >
{t('applications.open')} {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() 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', () => { it('filters the shared order by enablement only, including reordered always-shown apps', () => {
const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true, heartbeatEnabled: true, const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true, heartbeatEnabled: true,
applicationNavigation: { order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] as ApplicationSettings['applicationNavigation']['order'], 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 onOpen = vi.fn()
const { rerender } = render(<Harness value={value} onOpen={onOpen} />) const { rerender } = render(<Harness value={value} onOpen={onOpen} />)
fireEvent.click(screen.getByText('Launcher')) 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 { LayoutGrid } from 'lucide-react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import { defaultApplicationNavigation, type ApplicationSettings, type BuiltInApplicationId } from '../../shared/application-settings-contracts' 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' import { AnchoredMenu } from './AnchoredMenu'
export function ApplicationMenu({ anchorRef, settings, pending, error, onClose, onOpen, onManage, onRetry }: { 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 applications = useMemo(() => {
const order = (settings?.applicationNavigation ?? defaultApplicationNavigation).order const order = (settings?.applicationNavigation ?? defaultApplicationNavigation).order
return order return order
.filter(id => isApplicationEnabled(settings, id)) .filter(id => isApplicationOpenable(settings, id))
}, [settings]) }, [settings])
return <AnchoredMenu anchorRef={anchorRef} id="application-menu" label={t('applications.title')} onClose={onClose}> return <AnchoredMenu anchorRef={anchorRef} id="application-menu" label={t('applications.title')} onClose={onClose}>
{pending && <p role="status">{t('route.loading')}</p>} {pending && <p role="status">{t('route.loading')}</p>}
@@ -1,16 +1,10 @@
import { import {
Cpu,
Download,
FolderOpen, FolderOpen,
RefreshCw,
RotateCcw, RotateCcw,
Save, Save
Upload
} from 'lucide-react' } from 'lucide-react'
import { import {
useCallback,
useEffect, useEffect,
useRef,
useState useState
} from 'react' } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
@@ -18,13 +12,6 @@ import { InlineHelp } from './InlineHelp'
import type { import type {
ApplicationSettings ApplicationSettings
} from '../../shared/application-settings-contracts' } from '../../shared/application-settings-contracts'
import type {
AgentPackageDownloadProgress,
AgentPackageInventory
} from '../../shared/agent-package-contracts'
import type {
AgentArchitecture
} from '../../shared/agent-installation-contracts'
import { import {
canonicalizeShortcutAccelerator, canonicalizeShortcutAccelerator,
type GlobalShortcutSettings, type GlobalShortcutSettings,
@@ -32,7 +19,6 @@ import {
type GlobalShortcutUpdateErrorCode type GlobalShortcutUpdateErrorCode
} from '../../shared/shortcut' } from '../../shared/shortcut'
import type { AppNotificationInput } from './notifications' import type { AppNotificationInput } from './notifications'
import { PageTabs } from './WorkspacePrimitives'
import { import {
SettingsCategoryHeader, SettingsCategoryHeader,
SettingsWarningList SettingsWarningList
@@ -43,7 +29,6 @@ type PlatformFeaturesSettingsSectionProps = {
onConversationHtmlRenderingEnabledChange: ( onConversationHtmlRenderingEnabledChange: (
enabled: boolean enabled: boolean
) => void ) => void
onRemoteProjectsEnabledChange: (enabled: boolean) => void
onWorkspacePathChange: (path: string) => void onWorkspacePathChange: (path: string) => void
workspacePath: string workspacePath: string
onNotify?: (notification: AppNotificationInput) => void onNotify?: (notification: AppNotificationInput) => void
@@ -53,8 +38,6 @@ type PlatformFeaturesSettingsSectionProps = {
) => void ) => void
} }
type PlatformFeaturesTab = 'general' | 'remote-projects'
const shortcutErrorTranslationKeys: Record< const shortcutErrorTranslationKeys: Record<
GlobalShortcutUpdateErrorCode, GlobalShortcutUpdateErrorCode,
| 'platformFeatures.shortcut.errors.conflict' | 'platformFeatures.shortcut.errors.conflict'
@@ -69,7 +52,6 @@ const shortcutErrorTranslationKeys: Record<
export function PlatformFeaturesSettingsSection({ export function PlatformFeaturesSettingsSection({
onConversationHtmlRenderingEnabledChange, onConversationHtmlRenderingEnabledChange,
onRemoteProjectsEnabledChange,
onWorkspacePathChange, onWorkspacePathChange,
workspacePath, workspacePath,
onNotify, onNotify,
@@ -78,8 +60,6 @@ export function PlatformFeaturesSettingsSection({
}: PlatformFeaturesSettingsSectionProps): React.JSX.Element { }: PlatformFeaturesSettingsSectionProps): React.JSX.Element {
const { t } = useTranslation('settingsSections') const { t } = useTranslation('settingsSections')
const { t: settingsT } = useTranslation('settings') const { t: settingsT } = useTranslation('settings')
const [activeSection, setActiveSection] =
useState<PlatformFeaturesTab>('general')
const [settings, setSettings] = useState<ApplicationSettings>() const [settings, setSettings] = useState<ApplicationSettings>()
const [shortcutSnapshot, setShortcutSnapshot] = const [shortcutSnapshot, setShortcutSnapshot] =
useState<GlobalShortcutSettingsSnapshot>() useState<GlobalShortcutSettingsSnapshot>()
@@ -93,17 +73,6 @@ export function PlatformFeaturesSettingsSection({
: t('platformFeatures.shortcut.errors.serviceUnavailable') : t('platformFeatures.shortcut.errors.serviceUnavailable')
) )
const [saving, setSaving] = useState(false) 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>(() => const [error, setError] = useState<string | undefined>(() =>
window.goodbuddy.updates window.goodbuddy.updates
? undefined ? undefined
@@ -135,162 +104,6 @@ export function PlatformFeaturesSettingsSection({
} }
}, [t]) }, [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(() => { useEffect(() => {
const shortcuts = window.goodbuddy.shortcuts const shortcuts = window.goodbuddy.shortcuts
let active = true let active = true
@@ -475,68 +288,19 @@ 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 ( return (
<> <>
<SettingsCategoryHeader <SettingsCategoryHeader
category="platform-features" category="platform-features"
error={error} error={error}
headingId="platform-features-heading" 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} /> <SettingsWarningList warnings={settings?.warnings} />
{activeSection === 'general' && (
<section <section
aria-labelledby="platform-features-tab-general" aria-labelledby="platform-features-heading"
className="settings-section" className="settings-section"
id="platform-features-panel-general" id="platform-features-panel-general"
role="tabpanel"
> >
<article className="capability-card"> <article className="capability-card">
<div className="capability-card__header"> <div className="capability-card__header">
@@ -741,303 +505,6 @@ export function PlatformFeaturesSettingsSection({
</label> </label>
</article> </article>
</section> </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>
)}
</> </>
) )
} }
+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 () => { onUpdateProject: vi.fn(async () => {
throw new Error('Project update is not used in this test') throw new Error('Project update is not used in this test')
}), }),
projects: [], projects: []
remoteProjectsEnabled: true
} }
const assistantExpert: AssistantExpert = { const assistantExpert: AssistantExpert = {
id: '00000000-0000-4000-8000-000000000101', id: '00000000-0000-4000-8000-000000000101',
@@ -2035,12 +2034,37 @@ describe('SettingsPanel runtime files', () => {
expect(within(navigation).getAllByRole('tab').map(tab => tab.id)).toEqual([ expect(within(navigation).getAllByRole('tab').map(tab => tab.id)).toEqual([
'settings-tab-appearance', 'settings-tab-platform-features', 'settings-tab-appearance', 'settings-tab-platform-features',
'settings-tab-model', 'settings-tab-context-control', 'settings-tab-runtime', '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-document-parsing', 'settings-tab-channels', 'settings-tab-roles',
'settings-tab-capabilities', 'settings-tab-about' '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', () => { it('omits the redundant close-only footer on passive settings pages', () => {
render( render(
<SettingsPanel <SettingsPanel
@@ -2356,304 +2380,6 @@ describe('SettingsPanel runtime files', () => {
).toBeInTheDocument() ).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 () => { it('records physical Control separately from Command on macOS', async () => {
shortcutSettingsSnapshot = { shortcutSettingsSnapshot = {
...shortcutSettingsSnapshot, ...shortcutSettingsSnapshot,
@@ -2782,59 +2508,6 @@ describe('SettingsPanel runtime files', () => {
expect(onClose).not.toHaveBeenCalled() 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 () => { it('keeps shortcut input after a registration conflict', async () => {
updateShortcutSettings.mockResolvedValueOnce({ updateShortcutSettings.mockResolvedValueOnce({
ok: false, ok: false,
@@ -2983,15 +2656,15 @@ describe('SettingsPanel runtime files', () => {
runtimeTab.focus() runtimeTab.focus()
fireEvent.keyDown(runtimeTab, { key: 'ArrowRight' }) fireEvent.keyDown(runtimeTab, { key: 'ArrowRight' })
const sshHostsTab = screen.getByRole('tab', { const documentParsingTab = screen.getByRole('tab', {
name: '主机与远程执行' name: '文档解析'
}) })
expect(sshHostsTab).toHaveFocus() expect(documentParsingTab).toHaveFocus()
expect(sshHostsTab).toHaveAttribute('aria-selected', 'true') expect(documentParsingTab).toHaveAttribute('aria-selected', 'true')
expect(sshHostsTab).toHaveAttribute('tabindex', '0') expect(documentParsingTab).toHaveAttribute('tabindex', '0')
expect(runtimeTab).toHaveAttribute('tabindex', '-1') expect(runtimeTab).toHaveAttribute('tabindex', '-1')
fireEvent.keyDown(sshHostsTab, { key: 'End' }) fireEvent.keyDown(documentParsingTab, { key: 'End' })
const aboutTab = screen.getByRole('tab', { name: '关于与更新' }) const aboutTab = screen.getByRole('tab', { name: '关于与更新' })
expect(aboutTab).toHaveFocus() expect(aboutTab).toHaveFocus()
expect(aboutTab).toHaveAttribute('aria-selected', 'true') expect(aboutTab).toHaveAttribute('aria-selected', 'true')
@@ -4421,9 +4094,6 @@ describe('SettingsPanel runtime files', () => {
.getByRole('heading', { level: 2, name: '平台功能' }) .getByRole('heading', { level: 2, name: '平台功能' })
.closest('.settings-category-header') .closest('.settings-category-header')
expect(platformHeader).toHaveClass('settings-category-header--sticky') expect(platformHeader).toHaveClass('settings-category-header--sticky')
expect(
platformHeader?.querySelector('.platform-features-tabs')
).toBeInTheDocument()
fireEvent.click(screen.getByRole('tab', { name: '能力与工具' })) fireEvent.click(screen.getByRole('tab', { name: '能力与工具' }))
const capabilitiesHeader = screen const capabilitiesHeader = screen
+6 -41
View File
@@ -61,7 +61,6 @@ import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection' import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { DshMarketplaceSection } from './DshMarketplaceSection' import { DshMarketplaceSection } from './DshMarketplaceSection'
import { displayErrorMessage } from './error-message' import { displayErrorMessage } from './error-message'
import { SshHostsSettingsSection } from './SshHostsSettingsSection'
import { import {
RuntimeCustomizationSection, RuntimeCustomizationSection,
type RuntimeCustomizationSectionHandle type RuntimeCustomizationSectionHandle
@@ -227,7 +226,6 @@ type SettingsPanelProps = {
initialCategory?: SettingsCategoryId initialCategory?: SettingsCategoryId
initialChannel?: ProjectChannel initialChannel?: ProjectChannel
onClose: () => void onClose: () => void
onProjectsDeleted?: (projectIds: string[]) => void
onSaved: (settings: RuntimeSettings) => void onSaved: (settings: RuntimeSettings) => void
onNotify?: (notification: AppNotificationInput) => void onNotify?: (notification: AppNotificationInput) => void
onUpdateProject: ( onUpdateProject: (
@@ -253,11 +251,9 @@ type SettingsPanelProps = {
preferences: BrandingPreferences preferences: BrandingPreferences
) => boolean ) => boolean
magicNotesEnabled?: boolean magicNotesEnabled?: boolean
remoteProjectsEnabled?: boolean
onConversationHtmlRenderingEnabledChange?: ( onConversationHtmlRenderingEnabledChange?: (
enabled: boolean enabled: boolean
) => void ) => void
onRemoteProjectsEnabledChange?: (enabled: boolean) => void
onShortcutSettingsChanged?: ( onShortcutSettingsChanged?: (
snapshot: GlobalShortcutSettingsSnapshot snapshot: GlobalShortcutSettingsSnapshot
) => void ) => void
@@ -647,7 +643,6 @@ function SettingsPanelView({
initialCategory, initialCategory,
initialChannel, initialChannel,
onClose, onClose,
onProjectsDeleted = () => {},
onSaved, onSaved,
onNotify = () => {}, onNotify = () => {},
onUpdateProject, onUpdateProject,
@@ -668,9 +663,7 @@ function SettingsPanelView({
brandingFallbackLogo, brandingFallbackLogo,
onBrandingPreferencesChange = () => false, onBrandingPreferencesChange = () => false,
magicNotesEnabled = false, magicNotesEnabled = false,
remoteProjectsEnabled = false,
onConversationHtmlRenderingEnabledChange = () => {}, onConversationHtmlRenderingEnabledChange = () => {},
onRemoteProjectsEnabledChange = () => {},
onShortcutSettingsChanged = () => {}, onShortcutSettingsChanged = () => {},
onLeaveRequestReady = () => {} onLeaveRequestReady = () => {}
}: SettingsPanelProps): React.JSX.Element | null { }: SettingsPanelProps): React.JSX.Element | null {
@@ -797,15 +790,8 @@ function SettingsPanelView({
const [detection, setDetection] = useState<AgentRuntimeDetection>() const [detection, setDetection] = useState<AgentRuntimeDetection>()
const [detecting, setDetecting] = useState(false) const [detecting, setDetecting] = useState(false)
const [selectedTab, setActiveTab] = const [selectedTab, setActiveTab] =
useState<SettingsCategoryId>( useState<SettingsCategoryId>(initialCategory ?? 'runtime')
initialCategory === 'ssh-hosts' && !remoteProjectsEnabled const activeTab = selectedTab
? 'platform-features'
: initialCategory ?? 'runtime'
)
const activeTab =
selectedTab === 'ssh-hosts' && !remoteProjectsEnabled
? 'platform-features'
: selectedTab
const [modelType, setModelType] = useState<ModelType>('llm') const [modelType, setModelType] = useState<ModelType>('llm')
const [modelDownloadSourceOpen, setModelDownloadSourceOpen] = useState(false) const [modelDownloadSourceOpen, setModelDownloadSourceOpen] = useState(false)
const [modelDownloadSource, setModelDownloadSource] = useState<ModelDownloadSource>() const [modelDownloadSource, setModelDownloadSource] = useState<ModelDownloadSource>()
@@ -829,7 +815,6 @@ function SettingsPanelView({
useState(false) useState(false)
const [documentParsingDirty, setDocumentParsingDirty] = const [documentParsingDirty, setDocumentParsingDirty] =
useState(false) useState(false)
const [sshHostsDirty, setSshHostsDirty] = useState(false)
const [pendingLeave, setPendingLeave] = useState< const [pendingLeave, setPendingLeave] = useState<
| { kind: 'close' } | { kind: 'close' }
| { kind: 'navigate'; category: SettingsCategoryId; openModelDownloadSource?: boolean } | { kind: 'navigate'; category: SettingsCategoryId; openModelDownloadSource?: boolean }
@@ -915,7 +900,6 @@ function SettingsPanelView({
const categoryRendersOwnHeader = const categoryRendersOwnHeader =
activeTab === 'platform-features' || activeTab === 'platform-features' ||
activeTab === 'document-parsing' || activeTab === 'document-parsing' ||
activeTab === 'ssh-hosts' ||
activeTab === 'channels' || activeTab === 'channels' ||
activeTab === 'capabilities' || activeTab === 'capabilities' ||
activeTab === 'about' activeTab === 'about'
@@ -1018,22 +1002,19 @@ function SettingsPanelView({
runtimeCustomizationDirty || runtimeCustomizationDirty ||
brandingDirty || brandingDirty ||
platformFeaturesDirty || platformFeaturesDirty ||
documentParsingDirty || documentParsingDirty
sshHostsDirty
const navigationWouldLoseDraft = const navigationWouldLoseDraft =
runtimeCustomizationDirty || runtimeCustomizationDirty ||
brandingDirty || brandingDirty ||
platformFeaturesDirty || platformFeaturesDirty ||
documentParsingDirty || documentParsingDirty
sshHostsDirty
const unsavedCloseMessage = const unsavedCloseMessage =
runtimeCustomizationDirty && runtimeCustomizationDirty &&
!runtimeDraftDirty && !runtimeDraftDirty &&
!speechModelSelectionDirty && !speechModelSelectionDirty &&
!brandingDirty && !brandingDirty &&
!platformFeaturesDirty && !platformFeaturesDirty &&
!documentParsingDirty && !documentParsingDirty
!sshHostsDirty
? t('runtime.customization.unsavedClose') ? t('runtime.customization.unsavedClose')
: t('unsaved.close') : t('unsaved.close')
@@ -1077,9 +1058,7 @@ function SettingsPanelView({
if (requestTabChange('model', undefined, true)) setModelDownloadSourceOpen(true) if (requestTabChange('model', undefined, true)) setModelDownloadSourceOpen(true)
} }
const visibleSettingsCategories = getSettingsCategoryList( const visibleSettingsCategories = getSettingsCategoryList()
remoteProjectsEnabled
)
const settingsTabs = visibleSettingsCategories.map(({ id }) => id) const settingsTabs = visibleSettingsCategories.map(({ id }) => id)
const handleTabKeyDown = ( const handleTabKeyDown = (
@@ -1131,7 +1110,6 @@ function SettingsPanelView({
setSpeechModelSelectionDirty(false) setSpeechModelSelectionDirty(false)
setPlatformFeaturesDirty(false) setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false) setDocumentParsingDirty(false)
setSshHostsDirty(false)
setPendingLeave(undefined) setPendingLeave(undefined)
setAgentRuntimeType('opencode') setAgentRuntimeType('opencode')
hydrateSettings(value) hydrateSettings(value)
@@ -1300,7 +1278,6 @@ function SettingsPanelView({
setBrandingDirty(false) setBrandingDirty(false)
setPlatformFeaturesDirty(false) setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false) setDocumentParsingDirty(false)
setSshHostsDirty(false)
setPendingLeave(undefined) setPendingLeave(undefined)
setError(undefined) setError(undefined)
} }
@@ -1326,7 +1303,6 @@ function SettingsPanelView({
setBrandingDirty(false) setBrandingDirty(false)
setPlatformFeaturesDirty(false) setPlatformFeaturesDirty(false)
setDocumentParsingDirty(false) setDocumentParsingDirty(false)
setSshHostsDirty(false)
const leave = pendingLeave const leave = pendingLeave
setPendingLeave(undefined) setPendingLeave(undefined)
setError(undefined) setError(undefined)
@@ -2464,9 +2440,6 @@ function SettingsPanelView({
onConversationHtmlRenderingEnabledChange={ onConversationHtmlRenderingEnabledChange={
onConversationHtmlRenderingEnabledChange onConversationHtmlRenderingEnabledChange
} }
onRemoteProjectsEnabledChange={
onRemoteProjectsEnabledChange
}
onWorkspacePathChange={setWorkspacePath} onWorkspacePathChange={setWorkspacePath}
workspacePath={workspacePath} workspacePath={workspacePath}
onNotify={onNotify} onNotify={onNotify}
@@ -4159,14 +4132,6 @@ function SettingsPanelView({
/> />
)} )}
{remoteProjectsEnabled && activeTab === 'ssh-hosts' && (
<SshHostsSettingsSection
onDirtyChange={setSshHostsDirty}
onNotify={onNotify}
onProjectsDeleted={onProjectsDeleted}
/>
)}
{activeTab === 'platform-features' && ( {activeTab === 'platform-features' && (
<> <>
<div className="settings-section settings-section--danger"> <div className="settings-section settings-section--danger">
@@ -204,10 +204,18 @@ async function inspectAndConfirmFirstKey(): Promise<void> {
fireEvent.click(continueButton) 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( async function refreshHostEnvironment(
name = 'Build host' name = 'Build host'
): Promise<HTMLElement> { ): Promise<HTMLElement> {
const host = await screen.findByRole('region', { name }) const host = await screen.findByRole('region', { name })
expandHost(host, name)
fireEvent.click( fireEvent.click(
within(host).getByRole('button', { within(host).getByRole('button', {
name: `刷新 ${name} 的远程运行环境版本` name: `刷新 ${name} 的远程运行环境版本`
@@ -695,7 +703,7 @@ describe('SshHostsSettingsSection', () => {
}) })
fireEvent.click( fireEvent.click(
within(host).getByRole('button', { name: '删除' }) within(host).getByRole('button', { name: '删除 Build host' })
) )
const confirmation = screen.getByRole('alertdialog') const confirmation = screen.getByRole('alertdialog')
expect( expect(
@@ -737,7 +745,8 @@ describe('SshHostsSettingsSection', () => {
name: '验证 Build host' name: '验证 Build host'
}) })
).toHaveTextContent('验证并保存') ).toHaveTextContent('验证并保存')
expect(screen.getByText('需要重新验证')).toBeInTheDocument() // Shown as a summary badge and in the expanded details.
expect(screen.getAllByText('需要重新验证')).toHaveLength(2)
expect(getRemoteEnvironment).not.toHaveBeenCalled() expect(getRemoteEnvironment).not.toHaveBeenCalled()
}) })
@@ -773,6 +782,10 @@ describe('SshHostsSettingsSection', () => {
expect(within(host).getByText('0.10.4')).toBeInTheDocument() expect(within(host).getByText('0.10.4')).toBeInTheDocument()
expect(within(host).getByText('0.11.1')).toBeInTheDocument() expect(within(host).getByText('0.11.1')).toBeInTheDocument()
expect(within(host).getAllByText('1.18.29')).toHaveLength(2) 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).getByText('安装方式')).toBeInTheDocument()
expect( expect(
within(host) within(host)
@@ -822,13 +835,28 @@ describe('SshHostsSettingsSection', () => {
}) })
render(<SshHostsSettingsSection />) render(<SshHostsSettingsSection />)
await screen.findByRole('region', { name: 'Deploy host' }) const deployHost = await screen.findByRole('region', { name: 'Deploy host' })
expect(getRemoteEnvironment).not.toHaveBeenCalled() 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( 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() await refreshHostEnvironment()
expect(getRemoteEnvironment).toHaveBeenCalledTimes(1) expect(getRemoteEnvironment).toHaveBeenCalledTimes(1)
@@ -1281,7 +1309,7 @@ describe('SshHostsSettingsSection', () => {
}) })
).toBeDisabled() ).toBeDisabled()
expect( expect(
within(buildHost).getByRole('button', { name: '删除' }) within(buildHost).getByRole('button', { name: '删除 Build host' })
).toBeDisabled() ).toBeDisabled()
expect( expect(
within(deployHost).getByRole('button', { within(deployHost).getByRole('button', {
+241 -102
View File
@@ -2,11 +2,12 @@ import {
Bot, Bot,
Boxes, Boxes,
CheckCircle2, CheckCircle2,
ChevronRight,
Download, Download,
LoaderCircle, LoaderCircle,
Pencil,
Plus, Plus,
RefreshCw, RefreshCw,
Search,
Server, Server,
ShieldCheck, ShieldCheck,
X X
@@ -28,9 +29,9 @@ import type {
SshHostValidationResult SshHostValidationResult
} from '../../shared/ssh-host-contracts' } from '../../shared/ssh-host-contracts'
import type { AppNotificationInput } from './notifications' import type { AppNotificationInput } from './notifications'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import { displayErrorMessage } from './error-message' import { displayErrorMessage } from './error-message'
import { SshHostDialog } from './SshHostDialog' import { SshHostDialog } from './SshHostDialog'
import './remote-hosts.css'
import { import {
getCachedSshHostRemoteEnvironments, getCachedSshHostRemoteEnvironments,
removeCachedSshHostRemoteEnvironment, removeCachedSshHostRemoteEnvironment,
@@ -43,6 +44,7 @@ import {
} from './WorkspacePrimitives' } from './WorkspacePrimitives'
type SshHostsSettingsSectionProps = { type SshHostsSettingsSectionProps = {
onBusyChange?: (busy: boolean) => void
onDirtyChange?: (dirty: boolean) => void onDirtyChange?: (dirty: boolean) => void
onHostUpdated?: (hostId: string) => void onHostUpdated?: (hostId: string) => void
onNotify?: (notification: AppNotificationInput) => void onNotify?: (notification: AppNotificationInput) => void
@@ -95,6 +97,7 @@ function upsertHost(hosts: SshHost[], host: SshHost): SshHost[] {
} }
export function SshHostsSettingsSection({ export function SshHostsSettingsSection({
onBusyChange,
onDirtyChange, onDirtyChange,
onHostUpdated, onHostUpdated,
onNotify, onNotify,
@@ -110,6 +113,14 @@ export function SshHostsSettingsSection({
const [loadError, setLoadError] = useState<string>() const [loadError, setLoadError] = useState<string>()
const [error, setError] = useState<string>() const [error, setError] = useState<string>()
const [editingId, setEditingId] = useState<string | 'new'>() 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 [busyHostId, setBusyHostId] = useState<string>()
const [confirmingDeleteId, setConfirmingDeleteId] = const [confirmingDeleteId, setConfirmingDeleteId] =
useState<string>() useState<string>()
@@ -510,6 +521,8 @@ export function SshHostsSettingsSection({
const edited = hosts.some((host) => host.id === result.host.id) const edited = hosts.some((host) => host.id === result.host.id)
removeCachedSshHostRemoteEnvironment(result.host.id) removeCachedSshHostRemoteEnvironment(result.host.id)
setHosts((current) => upsertHost(current, result.host)) 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) => ({ setTestResults((current) => ({
...current, ...current,
[result.host.id]: result.connection [result.host.id]: result.connection
@@ -604,10 +617,35 @@ export function SshHostsSettingsSection({
const hasActiveRemoteEnvironmentUpdate = const hasActiveRemoteEnvironmentUpdate =
remoteEnvironmentUpdate !== undefined 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 ( return (
<div className="ssh-hosts-settings"> <div className="ssh-hosts-settings">
<SettingsCategoryHeader <div className="ssh-hosts-toolbar">
actions={ {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 <button
className="primary-button" className="primary-button"
disabled={loading || Boolean(editingId) || !api} disabled={loading || Boolean(editingId) || !api}
@@ -620,11 +658,12 @@ export function SshHostsSettingsSection({
<Plus aria-hidden="true" size={14} /> <Plus aria-hidden="true" size={14} />
{t('sshHosts.actions.add')} {t('sshHosts.actions.add')}
</button> </button>
} </div>
category="ssh-hosts" {error && (
error={error} <p className="settings-warning" role="alert">
headingId="ssh-hosts-heading" {error}
/> </p>
)}
<div className="settings-notice"> <div className="settings-notice">
<ShieldCheck aria-hidden="true" size={16} /> <ShieldCheck aria-hidden="true" size={16} />
@@ -676,12 +715,26 @@ export function SshHostsSettingsSection({
icon={<Server size={22} />} icon={<Server size={22} />}
title={t('sshHosts.empty.title')} 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 <div
aria-label={t('sshHosts.listLabel')} aria-label={t('sshHosts.listLabel')}
className="ssh-hosts-list" className="ssh-hosts-list"
> >
{hosts.map((host) => { {visibleHosts.map((host) => {
const relatedProjects = const relatedProjects =
projectReferences?.[host.id] ?? [] projectReferences?.[host.id] ?? []
const result = testResults[host.id] const result = testResults[host.id]
@@ -722,20 +775,98 @@ export function SshHostsSettingsSection({
'function' && 'function' &&
typeof api.onRemoteEnvironmentUpdateProgress === typeof api.onRemoteEnvironmentUpdateProgress ===
'function' '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 ( return (
<section <section
aria-label={host.name} aria-label={host.name}
className="settings-section ssh-host-card" className={`settings-section ssh-host-card${expanded ? ' ssh-host-card--expanded' : ''}`}
key={host.id} key={host.id}
> >
<div className="settings-section__title settings-section__title--actions"> <div className="ssh-host-card__summary">
<Server aria-hidden="true" size={17} /> <button
<div> 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> <strong>{host.name}</strong>
<small> <small>
{host.username}@{host.hostname}:{host.port} {host.username}@{host.hostname}:{host.port}
</small> </small>
</div> </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 <button
aria-label={t( aria-label={t(
host.lastValidatedAt host.lastValidatedAt
@@ -755,13 +886,57 @@ export function SshHostsSettingsSection({
}} }}
type="button" type="button"
> >
<Pencil aria-hidden="true" size={13} />
{host.lastValidatedAt {host.lastValidatedAt
? t('sshHosts.actions.edit') ? t('sshHosts.actions.edit')
: t('sshHosts.actions.validate')} : t('sshHosts.actions.validate')}
</button> </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> </div>
{expanded && (
<div className="ssh-host-card__details" id={detailsId}>
<dl className="ssh-host-card__facts"> <dl className="ssh-host-card__facts">
<div> <div>
<dt>{t('sshHosts.fields.authentication')}</dt> <dt>{t('sshHosts.fields.authentication')}</dt>
@@ -1053,9 +1228,18 @@ export function SshHostsSettingsSection({
)} )}
{remoteEnvironment?.value ? ( {remoteEnvironment?.value ? (
<div className="ssh-host-environment__grid"> <table className="ssh-host-environment__table">
<EnvironmentVersionCard <thead>
icon={<Bot aria-hidden="true" size={17} />} <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={ installed={
remoteEnvironment.value.agent.installed remoteEnvironment.value.agent.installed
} }
@@ -1068,9 +1252,9 @@ export function SshHostsSettingsSection({
/> />
{remoteEnvironment.value.runtimes.map( {remoteEnvironment.value.runtimes.map(
(runtime) => ( (runtime) => (
<EnvironmentVersionCard <EnvironmentVersionRow
icon={ icon={
<Server aria-hidden="true" size={17} /> <Server aria-hidden="true" size={15} />
} }
installed={runtime.installed} installed={runtime.installed}
key={runtime.runtimeId} key={runtime.runtimeId}
@@ -1085,7 +1269,8 @@ export function SshHostsSettingsSection({
/> />
) )
)} )}
</div> </tbody>
</table>
) : remoteEnvironment?.loading ? ( ) : remoteEnvironment?.loading ? (
<div <div
className="ssh-host-environment__loading" className="ssh-host-environment__loading"
@@ -1106,50 +1291,8 @@ export function SshHostsSettingsSection({
</div> </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> </div>
)}
</section> </section>
) )
})} })}
@@ -1198,7 +1341,7 @@ function formatPackageSize(bytes: number): string {
return `${mebibytes >= 10 ? Math.round(mebibytes) : mebibytes.toFixed(1)} MiB` return `${mebibytes >= 10 ? Math.round(mebibytes) : mebibytes.toFixed(1)} MiB`
} }
function EnvironmentVersionCard({ function EnvironmentVersionRow({
expected, expected,
icon, icon,
installed, installed,
@@ -1216,48 +1359,44 @@ function EnvironmentVersionCard({
t: ReturnType<typeof useTranslation<'settingsSections'>>['t'] t: ReturnType<typeof useTranslation<'settingsSections'>>['t']
}): React.JSX.Element { }): React.JSX.Element {
return ( return (
<article className="ssh-host-environment__runtime"> <tr>
<div className="ssh-host-environment__runtime-header"> <th scope="row">
<span className="ssh-host-environment__runtime-icon"> <span className="ssh-host-environment__component">
{icon} {icon}
{label}
</span> </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 <span
className={`ssh-host-environment__badge ssh-host-environment__badge--${state}`} className={`ssh-host-environment__badge ssh-host-environment__badge--${state}`}
> >
{t(`sshHosts.environment.states.${state}`)} {t(`sshHosts.environment.states.${state}`)}
</span> </span>
</div> </td>
<dl> </tr>
<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>
) )
} }
+10
View File
@@ -132,6 +132,16 @@
.application-settings .toggle-row span { .application-settings .toggle-row span {
font-weight: 500; 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 { .application-settings .settings-notice {
margin: 0 0 var(--space-2); margin: 0 0 var(--space-2);
font-size: var(--font-body); 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', 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', 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.', 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: { descriptions: {
'device-sharing': 'Test feature, off by default. Register devices, publish and revoke capability or knowledge metadata.', '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.', '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.' 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: { notifications: {
success: 'Success', success: 'Success',
error: 'Error', error: 'Error',
@@ -12,6 +12,25 @@ export const settingsSections = {
loading: 'Loading SSH hosts…', loading: 'Loading SSH hosts…',
loadFailedTitle: 'Could not load SSH hosts', loadFailedTitle: 'Could not load SSH hosts',
listLabel: 'SSH host list', 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: 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.', '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: secureStorageUnavailable:
@@ -137,6 +156,8 @@ export const settingsSections = {
reinstallFailedSummary: reinstallFailedSummary:
'This reinstall did not complete. Checking the current versions again.' 'This reinstall did not complete. Checking the current versions again.'
}, },
component: 'Component',
status: 'Status',
installed: 'Installed on Host', installed: 'Installed on Host',
expected: 'Required by GoodBuddy', expected: 'Required by GoodBuddy',
notInstalled: 'Not installed', notInstalled: 'Not installed',
@@ -200,6 +221,7 @@ export const settingsSections = {
validateNamed: 'Validate {{name}}', validateNamed: 'Validate {{name}}',
inspecting: 'Inspecting…', inspecting: 'Inspecting…',
remove: 'Delete', remove: 'Delete',
removeNamed: 'Delete {{name}}',
confirmRemove: 'Confirm delete', confirmRemove: 'Confirm delete',
cancel: 'Cancel', cancel: 'Cancel',
closeDialog: 'Close SSH host validation', closeDialog: 'Close SSH host validation',
@@ -147,17 +147,17 @@ export const workspace = {
loadingHosts: 'Loading saved hosts…', loadingHosts: 'Loading saved hosts…',
noHosts: 'No saved SSH hosts', noHosts: 'No saved SSH hosts',
hostHelp: 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: { readiness: {
loading: 'Reading the Host validation record…', loading: 'Reading the Host validation record…',
ready: ready:
'This Host is validated. Saving connects and checks the Agent, workspace, and Runtime.', 'This Host is validated. Saving connects and checks the Agent, workspace, and Runtime.',
unready: 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: 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: 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: { options: {
loading: 'Reading', loading: 'Reading',
ready: 'Validated', ready: 'Validated',
@@ -32,12 +32,27 @@ export const app = {
enabled: '已启用', disabled: '已关闭', open: '打开', moveUp: '上移 {{name}}', moveDown: '下移 {{name}}', enabled: '已启用', disabled: '已关闭', open: '打开', moveUp: '上移 {{name}}', moveDown: '下移 {{name}}',
orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败', orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败',
confirmFailed: '无法确认保存结果,请重新读取后再修改。', confirmFailed: '无法确认保存结果,请重新读取后再修改。',
remoteHosts: '主机管理',
remoteHostsEnable: '启用远程能力',
remoteHostsHelp: '远程能力默认关闭。启用后可以添加 SSH 主机、安装执行环境并创建远程项目。关闭后远程项目从项目列表中隐藏,也不能再在远端执行;主机、凭据、远程项目和 Agent 离线包都会保留。Agent 离线包在关闭时仍可管理。',
descriptions: { descriptions: {
'device-sharing': '测试功能,默认关闭。注册设备,发布和撤销能力或知识的元数据。', 'device-sharing': '测试功能,默认关闭。注册设备,发布和撤销能力或知识的元数据。',
'remote-hosts': '接入 SSH 主机,安装远端执行环境,管理 Agent 离线包。',
'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。', 'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。',
heartbeat: '回顾工作演变、查看故事线图谱,并配置智能心跳。', 'local-inference': '查看本机推理任务与执行服务。' heartbeat: '回顾工作演变、查看故事线图谱,并配置智能心跳。', 'local-inference': '查看本机推理任务与执行服务。'
} }
}, },
remoteHosts: {
title: '主机管理',
description: '接入 SSH 主机,安装远端执行环境,管理 Agent 离线包。本机配置的 Skills 和 MCP 不会应用到远程主机。',
tabsLabel: '主机管理',
tabs: { hosts: '主机', packages: 'Agent 离线包' },
disabled: {
title: '远程能力未启用',
description: '启用后可以添加 SSH 主机、安装执行环境并创建远程项目。Agent 离线包在未启用时也可以提前下载或导入。',
enable: '启用远程能力'
}
},
notifications: { notifications: {
success: '成功', success: '成功',
error: '错误', error: '错误',
@@ -7,6 +7,25 @@ export const settingsSections = {
loading: '正在读取 SSH 主机…', loading: '正在读取 SSH 主机…',
loadFailedTitle: 'SSH 主机加载失败', loadFailedTitle: 'SSH 主机加载失败',
listLabel: '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: securityNotice:
'添加或编辑主机时,GoodBuddy 会先检查 Host Key,再发送认证凭据并执行有界系统探测。只有全部成功后才会保存主机和加密密码。', '添加或编辑主机时,GoodBuddy 会先检查 Host Key,再发送认证凭据并执行有界系统探测。只有全部成功后才会保存主机和加密密码。',
secureStorageUnavailable: secureStorageUnavailable:
@@ -129,6 +148,8 @@ export const settingsSections = {
reinstallFailedSummary: reinstallFailedSummary:
'本次重新安装未完成;正在重新检查当前版本。' '本次重新安装未完成;正在重新检查当前版本。'
}, },
component: '组件',
status: '状态',
installed: 'Host 已安装', installed: 'Host 已安装',
expected: 'GoodBuddy 所需', expected: 'GoodBuddy 所需',
notInstalled: '尚未安装', notInstalled: '尚未安装',
@@ -192,6 +213,7 @@ export const settingsSections = {
validateNamed: '验证 {{name}}', validateNamed: '验证 {{name}}',
inspecting: '正在检查…', inspecting: '正在检查…',
remove: '删除', remove: '删除',
removeNamed: '删除 {{name}}',
confirmRemove: '确认删除', confirmRemove: '确认删除',
cancel: '取消', cancel: '取消',
closeDialog: '关闭 SSH 主机验证', closeDialog: '关闭 SSH 主机验证',
@@ -143,17 +143,17 @@ export const workspace = {
}, },
loadingHosts: '正在读取已保存主机…', loadingHosts: '正在读取已保存主机…',
noHosts: '没有已保存的 SSH 主机', noHosts: '没有已保存的 SSH 主机',
hostHelp: '主机凭据由设置中心统一管理,不会复制到项目中。', hostHelp: '主机凭据在“应用 > 主机管理”中统一管理,不会复制到项目中。',
readiness: { readiness: {
loading: '正在读取主机验证记录…', loading: '正在读取主机验证记录…',
ready: ready:
'此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。', '此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。',
unready: unready:
'此主机尚未完成 Host Key 和连接验证。请先前往“设置 > SSH 主机”完成验证。', '此主机尚未完成 Host Key 和连接验证。请先前往“应用 > 主机管理”完成验证。',
error: error:
'无法读取此主机的本地验证记录。请前往“设置 > SSH 主机”检查配置。', '无法读取此主机的本地验证记录。请前往“应用 > 主机管理”检查配置。',
saveBlocked: saveBlocked:
'所选主机尚未完成验证。请先在“设置 > SSH 主机”验证连接。', '所选主机尚未完成验证。请先在“应用 > 主机管理”验证连接。',
options: { options: {
loading: '读取中', loading: '读取中',
ready: '已验证', 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', () => { describe('getSettingsCategoryList', () => {
it('defines an icon for every settings category', () => { it('defines an icon for every settings category', () => {
expect( expect(
getSettingsCategoryList(true).every(({ icon }) => icon != null) getSettingsCategoryList().every(({ icon }) => icon != null)
).toBe(true) ).toBe(true)
}) })
it('hides SSH hosts while Remote Projects is disabled', () => { it('no longer lists SSH hosts, which moved to the Hosts application', () => {
expect( expect(
getSettingsCategoryList(false).map(({ id }) => id) getSettingsCategoryList().map(({ id }) => id as string)
).not.toContain('ssh-hosts') ).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, Info,
Palette, Palette,
Radio, Radio,
Server,
SlidersHorizontal, SlidersHorizontal,
Terminal, Terminal,
Users, Users,
@@ -44,12 +44,7 @@ export const settingsCategoryList = [
translationKey: 'runtime', translationKey: 'runtime',
icon: Terminal icon: Terminal
}, },
{
id: 'ssh-hosts',
group: 'execution',
translationKey: 'sshHosts',
icon: Server
},
{ {
id: 'document-parsing', id: 'document-parsing',
group: 'extensions', group: 'extensions',
@@ -86,12 +81,9 @@ export type SettingsCategoryDefinition =
(typeof settingsCategoryList)[number] (typeof settingsCategoryList)[number]
export type SettingsCategoryId = SettingsCategoryDefinition['id'] export type SettingsCategoryId = SettingsCategoryDefinition['id']
export function getSettingsCategoryList( // SSH hosts moved to the Hosts application and are no longer a settings category.
remoteProjectsEnabled: boolean export function getSettingsCategoryList(): readonly SettingsCategoryDefinition[] {
): readonly SettingsCategoryDefinition[] { return settingsCategoryList
return remoteProjectsEnabled
? settingsCategoryList
: settingsCategoryList.filter(({ id }) => id !== 'ssh-hosts')
} }
type LocalizedSettingsCategoryDefinition = SettingsCategoryDefinition & { 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 supervisorModelConcurrencySchema = z.number().int().min(1).max(4)
export const supervisionTimeoutSecondsSchema = z.number().int().min(30).max(600) 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 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 type EditableApplicationId = typeof editableApplicationIds[number]
export const defaultApplicationNavigation = { export const defaultApplicationNavigation = {
order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference', 'device-sharing'] as BuiltInApplicationId[], order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference', 'device-sharing', 'remote-hosts'] as BuiltInApplicationId[],
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': false } pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': false, 'remote-hosts': false }
} }
export const applicationNavigationSchema = z.object({ export const applicationNavigationSchema = z.object({
order: z.array(z.enum(builtInApplicationIds)).length(builtInApplicationIds.length) order: z.array(z.enum(builtInApplicationIds)).length(builtInApplicationIds.length)
@@ -51,7 +51,9 @@ export const applicationNavigationSchema = z.object({
pinned: z.object({ pinned: z.object({
'magic-notes': z.boolean(), 'magic-notes': z.boolean(),
heartbeat: z.boolean().default(true), 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()
}).strict() }).strict()
@@ -23,10 +23,14 @@ describe('GoodBuddy configuration contracts', () => {
operation: 'application.update', operation: 'application.update',
updates: { applicationNavigation: { updates: { applicationNavigation: {
...defaultApplicationNavigation, ...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) 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']]) { for (const order of [['magic-notes', 'local-inference'], ['knowledge', 'heartbeat', 'magic-notes', 'magic-notes'], ['knowledge', 'heartbeat', 'magic-notes', 'invalid']]) {
expect(goodbuddyConfigOperationSchema.safeParse({ ...operation, updates: { expect(goodbuddyConfigOperationSchema.safeParse({ ...operation, updates: {
applicationNavigation: { ...defaultApplicationNavigation, order }, applicationNavigation: { ...defaultApplicationNavigation, order },