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:
@@ -282,7 +282,7 @@ describe('ApplicationSettingsStore', () => {
|
||||
const settings = await store.get()
|
||||
expect(settings.warnings).toBeUndefined()
|
||||
expect(settings.magicNotesEnabled).toBe(false)
|
||||
expect(settings.applicationNavigation).toEqual({ order: [...expected, 'device-sharing'], pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false } })
|
||||
expect(settings.applicationNavigation).toEqual({ order: [...expected, 'device-sharing', 'remote-hosts'], pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': false } })
|
||||
expect(applicationSettingsSchema.parse(settings)).toEqual(settings)
|
||||
await store.update({ checkUpdatesOnStartup: false })
|
||||
expect(JSON.parse(await readFile(filePath, 'utf8')).applicationNavigation).toEqual(settings.applicationNavigation)
|
||||
@@ -292,8 +292,8 @@ describe('ApplicationSettingsStore', () => {
|
||||
it('validates full-order writes, persists and publishes them, and rejects partial writes without mutation', async () => {
|
||||
const { store, filePath } = await createStore()
|
||||
const applicationNavigation = {
|
||||
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'],
|
||||
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false },
|
||||
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'remote-hosts', 'device-sharing'],
|
||||
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': true },
|
||||
}
|
||||
const changed = vi.fn()
|
||||
store.onChanged(changed)
|
||||
@@ -337,11 +337,27 @@ describe('ApplicationSettingsStore', () => {
|
||||
pinned: { 'magic-notes': false, 'local-inference': pinned }
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, applicationNavigation, version: 11, lastSeenReleaseNotesVersion: null }), 'utf8')
|
||||
const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] }
|
||||
const normalized = { ...applicationNavigation, pinned: { ...applicationNavigation.pinned, heartbeat: true, 'remote-hosts': false }, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'] }
|
||||
expect((await store.get()).applicationNavigation).toEqual(normalized)
|
||||
await store.update({ checkUpdatesOnStartup: false })
|
||||
expect((await createApplicationSettingsStore(filePath).get()).applicationNavigation).toEqual(normalized)
|
||||
})
|
||||
it.each([true, false])('adds Hosts after a saved full order and keeps remote projects %s', async (remoteProjectsEnabled) => {
|
||||
const { filePath, store } = await createStore()
|
||||
const applicationNavigation = {
|
||||
order: ['device-sharing', 'local-inference', 'heartbeat', 'magic-notes', 'knowledge'],
|
||||
pinned: { 'magic-notes': false, heartbeat: false, 'local-inference': true }
|
||||
}
|
||||
await writeFile(filePath, JSON.stringify({ ...defaultApplicationSettings, remoteProjectsEnabled, applicationNavigation, version: 12, lastSeenReleaseNotesVersion: null }), 'utf8')
|
||||
const expected = { order: [...applicationNavigation.order, 'remote-hosts'], pinned: { ...applicationNavigation.pinned, 'remote-hosts': false } }
|
||||
const migrated = await store.get()
|
||||
expect(migrated.warnings).toBeUndefined()
|
||||
expect(migrated.remoteProjectsEnabled).toBe(remoteProjectsEnabled)
|
||||
expect(migrated.applicationNavigation).toEqual(expected)
|
||||
expect(applicationSettingsSchema.parse(migrated)).toEqual(migrated)
|
||||
await store.update({ checkUpdatesOnStartup: false })
|
||||
expect(await createApplicationSettingsStore(filePath).get()).toMatchObject({ remoteProjectsEnabled, applicationNavigation: expected })
|
||||
})
|
||||
it('migrates released settings without replacing explicit false values or comment choices', async () => {
|
||||
const { filePath, store } = await createStore()
|
||||
const legacy: Record<string, unknown> = { ...defaultApplicationSettings, version: 11, lastSeenReleaseNotesVersion: null, magicNotesEnabled: false, magicNotesShowIncompleteTodoCount: false, magicNoteCommentMode: 'after-save-manual', magicNoteCommentFormat: 'structured' }
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import {
|
||||
act,
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
within
|
||||
} from '@testing-library/react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { AgentPackageInventory } from '../../shared/agent-package-contracts'
|
||||
import { AgentPackageInventoryPanel } from './AgentPackageInventoryPanel'
|
||||
import { changeUiLocale } from './i18n'
|
||||
|
||||
const agentPackageInventory: AgentPackageInventory = {
|
||||
checkedAt: '2026-08-24T00:00:00.000Z',
|
||||
catalog: {
|
||||
state: 'available',
|
||||
checkedAt: '2026-08-24T00:00:00.000Z',
|
||||
error: null
|
||||
},
|
||||
entries: [
|
||||
{
|
||||
platform: 'linux',
|
||||
architecture: 'x64',
|
||||
state: 'verified',
|
||||
version: '0.11.2',
|
||||
latestVersion: '0.11.4',
|
||||
updateAvailable: true,
|
||||
agentProtocol: { major: 2, minor: 0 },
|
||||
remoteRuntimeVersion: '1.18.29'
|
||||
},
|
||||
{
|
||||
platform: 'linux',
|
||||
architecture: 'arm64',
|
||||
state: 'not-downloaded',
|
||||
version: null,
|
||||
latestVersion: '0.11.4',
|
||||
updateAvailable: false,
|
||||
agentProtocol: null,
|
||||
remoteRuntimeVersion: null
|
||||
}
|
||||
]
|
||||
}
|
||||
const getAgentPackageInventory = vi.fn<(refresh?: boolean) => Promise<AgentPackageInventory>>()
|
||||
const downloadAgentPackage = vi.fn<(architecture: string, platform: string) => Promise<AgentPackageInventory>>()
|
||||
const importAgentPackage = vi.fn<() => Promise<AgentPackageInventory | undefined>>()
|
||||
const exportAgentPackage = vi.fn<(architecture: string, platform: string) => Promise<void>>()
|
||||
const onAgentPackageProgress = vi.fn(() => () => {})
|
||||
|
||||
describe('AgentPackageInventoryPanel', () => {
|
||||
beforeEach(async () => {
|
||||
await changeUiLocale('zh-CN')
|
||||
vi.clearAllMocks()
|
||||
getAgentPackageInventory.mockResolvedValue(agentPackageInventory)
|
||||
downloadAgentPackage.mockResolvedValue(agentPackageInventory)
|
||||
importAgentPackage.mockResolvedValue(agentPackageInventory)
|
||||
exportAgentPackage.mockResolvedValue(undefined)
|
||||
Object.defineProperty(window, 'goodbuddy', {
|
||||
configurable: true,
|
||||
value: {
|
||||
sshHosts: {
|
||||
getAgentPackageInventory,
|
||||
downloadAgentPackage,
|
||||
importAgentPackage,
|
||||
exportAgentPackage,
|
||||
onAgentPackageProgress
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
afterEach(() => cleanup())
|
||||
|
||||
it('waits until shown, then lists, refreshes, downloads, exports, and imports packages', async () => {
|
||||
const onNotify = vi.fn()
|
||||
const onBusyChange = vi.fn()
|
||||
const { rerender } = render(
|
||||
<AgentPackageInventoryPanel active={false} onBusyChange={onBusyChange} onNotify={onNotify} />
|
||||
)
|
||||
expect(getAgentPackageInventory).not.toHaveBeenCalled()
|
||||
rerender(<AgentPackageInventoryPanel active onBusyChange={onBusyChange} onNotify={onNotify} />)
|
||||
|
||||
expect(await screen.findByText('GoodBuddy Agent 包')).toBeInTheDocument()
|
||||
expect(await screen.findByText('当前 1 / 2 个平台与架构目标可用。')).toBeInTheDocument()
|
||||
expect(screen.getByText('有更新')).toBeInTheDocument()
|
||||
expect(screen.getByText('未下载')).toBeInTheDocument()
|
||||
expect(getAgentPackageInventory).toHaveBeenCalledExactlyOnceWith(false)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新包清单' }))
|
||||
await waitFor(() => expect(getAgentPackageInventory).toHaveBeenCalledTimes(2))
|
||||
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, true)
|
||||
|
||||
const x64Card = screen.getByText('Linux x64').closest('[role="listitem"]') as HTMLElement
|
||||
const arm64Card = screen.getByText('Linux arm64').closest('[role="listitem"]') as HTMLElement
|
||||
downloadAgentPackage.mockResolvedValueOnce({
|
||||
...agentPackageInventory,
|
||||
entries: agentPackageInventory.entries.map((entry) =>
|
||||
entry.architecture === 'x64' ? { ...entry, version: '0.11.4', updateAvailable: false } : entry
|
||||
)
|
||||
})
|
||||
fireEvent.click(within(x64Card).getByRole('button', { name: '更新到 0.11.4' }))
|
||||
await waitFor(() => expect(downloadAgentPackage).toHaveBeenCalledWith('x64', 'linux'))
|
||||
expect(await within(x64Card).findByText('已是最新')).toBeInTheDocument()
|
||||
expect(onBusyChange).toHaveBeenCalledWith(true)
|
||||
await waitFor(() => expect(onBusyChange).toHaveBeenLastCalledWith(false))
|
||||
|
||||
fireEvent.click(within(x64Card).getByRole('button', { name: '导出离线包' }))
|
||||
await waitFor(() => expect(exportAgentPackage).toHaveBeenCalledWith('x64', 'linux'))
|
||||
await waitFor(() => expect(within(arm64Card).getByRole('button', { name: '下载 0.11.4' })).toBeEnabled())
|
||||
fireEvent.click(within(arm64Card).getByRole('button', { name: '下载 0.11.4' }))
|
||||
await waitFor(() => expect(downloadAgentPackage).toHaveBeenCalledWith('arm64', 'linux'))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '导入离线包' })).toBeEnabled())
|
||||
fireEvent.click(screen.getByRole('button', { name: '导入离线包' }))
|
||||
await waitFor(() => expect(importAgentPackage).toHaveBeenCalledOnce())
|
||||
await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ dedupeKey: 'agent-package-imported' })))
|
||||
})
|
||||
|
||||
it('shows loading and recovers from a refresh error', async () => {
|
||||
let resolveInventory: ((inventory: AgentPackageInventory) => void) | undefined
|
||||
getAgentPackageInventory.mockReturnValueOnce(
|
||||
new Promise<AgentPackageInventory>((resolve) => { resolveInventory = resolve })
|
||||
)
|
||||
render(<AgentPackageInventoryPanel />)
|
||||
expect(await screen.findByText('正在校验本地 Agent 包并检查在线版本…')).toBeInTheDocument()
|
||||
await act(async () => {
|
||||
resolveInventory?.(agentPackageInventory)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(await screen.findByText('当前 1 / 2 个平台与架构目标可用。')).toBeInTheDocument()
|
||||
|
||||
getAgentPackageInventory.mockRejectedValueOnce(new Error('inventory unavailable'))
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新包清单' }))
|
||||
await screen.findByText('inventory unavailable')
|
||||
fireEvent.click(screen.getByRole('button', { name: '刷新包清单' }))
|
||||
await waitFor(() => expect(screen.queryByText('inventory unavailable')).not.toBeInTheDocument())
|
||||
expect(getAgentPackageInventory).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('keeps local Agent packages usable when the online catalog is unavailable', async () => {
|
||||
getAgentPackageInventory.mockResolvedValueOnce({
|
||||
...agentPackageInventory,
|
||||
catalog: { state: 'unavailable', checkedAt: '2026-08-24T00:00:00.000Z', error: 'network unavailable' },
|
||||
entries: agentPackageInventory.entries.map((entry) => ({ ...entry, latestVersion: null, updateAvailable: false }))
|
||||
})
|
||||
render(<AgentPackageInventoryPanel />)
|
||||
expect(await screen.findByText(/network unavailable/u)).toBeInTheDocument()
|
||||
const x64Card = screen.getByText('Linux x64').closest('[role="listitem"]') as HTMLElement
|
||||
expect(within(x64Card).getByRole('button', { name: '检查并更新' })).toBeInTheDocument()
|
||||
expect(within(x64Card).getByRole('button', { name: '导出离线包' })).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,443 @@
|
||||
import { Cpu, Download, RefreshCw, Upload } from 'lucide-react'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import type {
|
||||
AgentPackageDownloadProgress,
|
||||
AgentPackageInventory
|
||||
} from '../../shared/agent-package-contracts'
|
||||
import type { AgentArchitecture } from '../../shared/agent-installation-contracts'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
|
||||
// Local Agent package inventory used to install and update remote hosts.
|
||||
// Package state lives in Main and is independent of host records.
|
||||
export function AgentPackageInventoryPanel({
|
||||
active = true,
|
||||
onBusyChange,
|
||||
onNotify
|
||||
}: {
|
||||
active?: boolean
|
||||
onBusyChange?: (busy: boolean) => void
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
}): React.JSX.Element {
|
||||
const { t } = useTranslation('settingsSections')
|
||||
const [agentInventory, setAgentInventory] =
|
||||
useState<AgentPackageInventory>()
|
||||
const [agentInventoryLoading, setAgentInventoryLoading] =
|
||||
useState(false)
|
||||
const [agentInventoryError, setAgentInventoryError] =
|
||||
useState<string>()
|
||||
const [agentPackageBusy, setAgentPackageBusy] =
|
||||
useState<string>()
|
||||
const [agentPackageProgress, setAgentPackageProgress] =
|
||||
useState<AgentPackageDownloadProgress>()
|
||||
const agentInventoryRequested = useRef(false)
|
||||
|
||||
useEffect(() => {
|
||||
onBusyChange?.(agentPackageBusy !== undefined)
|
||||
}, [agentPackageBusy, onBusyChange])
|
||||
useEffect(() => () => onBusyChange?.(false), [onBusyChange])
|
||||
|
||||
const loadAgentInventory = useCallback(async (
|
||||
refresh = false
|
||||
): Promise<void> => {
|
||||
const getInventory =
|
||||
window.goodbuddy.sshHosts?.getAgentPackageInventory
|
||||
if (!getInventory) {
|
||||
setAgentInventoryError(
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.unavailable'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
setAgentInventoryLoading(true)
|
||||
setAgentInventoryError(undefined)
|
||||
try {
|
||||
setAgentInventory(await getInventory(refresh))
|
||||
} catch (reason) {
|
||||
setAgentInventoryError(
|
||||
displayErrorMessage(
|
||||
reason,
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.readFailed'
|
||||
)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setAgentInventoryLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
// Reading the inventory checks the online catalog, so it waits until shown.
|
||||
useEffect(() => {
|
||||
if (!active || agentInventoryRequested.current) {
|
||||
return
|
||||
}
|
||||
agentInventoryRequested.current = true
|
||||
void loadAgentInventory()
|
||||
}, [active, loadAgentInventory])
|
||||
|
||||
useEffect(() => {
|
||||
const subscribe =
|
||||
window.goodbuddy.sshHosts?.onAgentPackageProgress
|
||||
return subscribe?.((progress) =>
|
||||
setAgentPackageProgress(progress)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const downloadAgentPackage = async (
|
||||
architecture: AgentArchitecture,
|
||||
platform: 'linux' | 'darwin'
|
||||
): Promise<void> => {
|
||||
const download =
|
||||
window.goodbuddy.sshHosts?.downloadAgentPackage
|
||||
if (!download) {
|
||||
return
|
||||
}
|
||||
setAgentPackageBusy(`${platform}-${architecture}`)
|
||||
setAgentPackageProgress(undefined)
|
||||
setAgentInventoryError(undefined)
|
||||
try {
|
||||
setAgentInventory(await download(architecture, platform))
|
||||
onNotify?.({
|
||||
dedupeKey: `agent-package-downloaded:${platform}-${architecture}`,
|
||||
message: t(
|
||||
'platformFeatures.remoteProjects.agentInventory.notifications.downloaded',
|
||||
{ architecture, platform: platform === 'darwin' ? 'macOS' : 'Linux' }
|
||||
),
|
||||
tone: 'success'
|
||||
})
|
||||
} catch (reason) {
|
||||
setAgentInventoryError(
|
||||
displayErrorMessage(
|
||||
reason,
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.downloadFailed'
|
||||
)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setAgentPackageBusy(undefined)
|
||||
setAgentPackageProgress(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const importAgentPackage = async (): Promise<void> => {
|
||||
const importPackage =
|
||||
window.goodbuddy.sshHosts?.importAgentPackage
|
||||
if (!importPackage) {
|
||||
return
|
||||
}
|
||||
setAgentPackageBusy('import')
|
||||
setAgentInventoryError(undefined)
|
||||
try {
|
||||
const inventory = await importPackage()
|
||||
if (inventory) {
|
||||
setAgentInventory(inventory)
|
||||
onNotify?.({
|
||||
dedupeKey: 'agent-package-imported',
|
||||
message: t(
|
||||
'platformFeatures.remoteProjects.agentInventory.notifications.imported'
|
||||
),
|
||||
tone: 'success'
|
||||
})
|
||||
}
|
||||
} catch (reason) {
|
||||
setAgentInventoryError(
|
||||
displayErrorMessage(
|
||||
reason,
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.importFailed'
|
||||
)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setAgentPackageBusy(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const exportAgentPackage = async (
|
||||
architecture: AgentArchitecture,
|
||||
platform: 'linux' | 'darwin'
|
||||
): Promise<void> => {
|
||||
const exportPackage =
|
||||
window.goodbuddy.sshHosts?.exportAgentPackage
|
||||
if (!exportPackage) {
|
||||
return
|
||||
}
|
||||
setAgentPackageBusy(`${platform}-${architecture}`)
|
||||
setAgentInventoryError(undefined)
|
||||
try {
|
||||
await exportPackage(architecture, platform)
|
||||
onNotify?.({
|
||||
dedupeKey: `agent-package-exported:${platform}-${architecture}`,
|
||||
message: t(
|
||||
'platformFeatures.remoteProjects.agentInventory.notifications.exported',
|
||||
{ architecture, platform: platform === 'darwin' ? 'macOS' : 'Linux' }
|
||||
),
|
||||
tone: 'success'
|
||||
})
|
||||
} catch (reason) {
|
||||
setAgentInventoryError(
|
||||
displayErrorMessage(
|
||||
reason,
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.exportFailed'
|
||||
)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setAgentPackageBusy(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.title'
|
||||
)}
|
||||
</strong>
|
||||
<small>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.description'
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
<div className="capability-card__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={agentPackageBusy !== undefined}
|
||||
onClick={() => void importAgentPackage()}
|
||||
type="button"
|
||||
>
|
||||
<Upload aria-hidden="true" size={13} />
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.import'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t(
|
||||
'platformFeatures.remoteProjects.agentInventory.refresh'
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={
|
||||
agentInventoryLoading ||
|
||||
agentPackageBusy !== undefined
|
||||
}
|
||||
onClick={() => void loadAgentInventory(true)}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw
|
||||
aria-hidden="true"
|
||||
className={
|
||||
agentInventoryLoading
|
||||
? 'bundled-agent-inventory__spinner'
|
||||
: undefined
|
||||
}
|
||||
size={13}
|
||||
/>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.refresh'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{agentInventory && (
|
||||
<p className="settings-notice">
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.summary',
|
||||
{
|
||||
available: agentInventory.entries.filter(
|
||||
(entry) => entry.state === 'verified'
|
||||
).length,
|
||||
total: agentInventory.entries.length
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{agentInventoryLoading && !agentInventory ? (
|
||||
<p className="settings-notice" role="status">
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.loading'
|
||||
)}
|
||||
</p>
|
||||
) : agentInventoryError ? (
|
||||
<p className="settings-warning" role="alert">
|
||||
{agentInventoryError}
|
||||
</p>
|
||||
) : agentInventory ? (
|
||||
<div
|
||||
aria-label={t(
|
||||
'platformFeatures.remoteProjects.agentInventory.listLabel'
|
||||
)}
|
||||
className="bundled-agent-inventory"
|
||||
role="list"
|
||||
>
|
||||
{agentInventory.entries.map((entry) => (
|
||||
<article
|
||||
className="bundled-agent-inventory__item"
|
||||
key={`${entry.platform}-${entry.architecture}`}
|
||||
role="listitem"
|
||||
>
|
||||
<div className="bundled-agent-inventory__item-header">
|
||||
<Cpu aria-hidden="true" size={16} />
|
||||
<strong>
|
||||
{entry.platform === 'darwin' ? 'macOS' : 'Linux'} {entry.architecture}
|
||||
</strong>
|
||||
<span
|
||||
className={`bundled-agent-inventory__badge bundled-agent-inventory__badge--${
|
||||
entry.updateAvailable
|
||||
? 'update-available'
|
||||
: agentInventory.catalog.state ===
|
||||
'available' &&
|
||||
entry.state === 'verified' &&
|
||||
entry.latestVersion !== null
|
||||
? 'up-to-date'
|
||||
: entry.state
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
entry.updateAvailable
|
||||
? 'platformFeatures.remoteProjects.agentInventory.states.updateAvailable'
|
||||
: agentInventory.catalog.state ===
|
||||
'available' &&
|
||||
entry.state === 'verified' &&
|
||||
entry.latestVersion !== null
|
||||
? 'platformFeatures.remoteProjects.agentInventory.states.upToDate'
|
||||
: `platformFeatures.remoteProjects.agentInventory.states.${entry.state}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.agentVersion'
|
||||
)}
|
||||
</dt>
|
||||
<dd>{entry.version ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.latestVersion'
|
||||
)}
|
||||
</dt>
|
||||
<dd>{entry.latestVersion ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.architecture'
|
||||
)}
|
||||
</dt>
|
||||
<dd>{entry.architecture}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.runtimeVersion'
|
||||
)}
|
||||
</dt>
|
||||
<dd>{entry.remoteRuntimeVersion ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.protocol'
|
||||
)}
|
||||
</dt>
|
||||
<dd>
|
||||
{entry.agentProtocol
|
||||
? `${entry.agentProtocol.major}.${entry.agentProtocol.minor}`
|
||||
: '—'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{agentPackageProgress?.architecture ===
|
||||
entry.architecture &&
|
||||
agentPackageProgress.platform === entry.platform &&
|
||||
agentPackageBusy === `${entry.platform}-${entry.architecture}` && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t(
|
||||
`platformFeatures.remoteProjects.agentInventory.progress.${agentPackageProgress.phase}`
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="capability-card__actions">
|
||||
{(
|
||||
entry.updateAvailable ||
|
||||
(
|
||||
entry.state !== 'verified' &&
|
||||
entry.latestVersion !== null
|
||||
) ||
|
||||
agentInventory.catalog.state !== 'available'
|
||||
) && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={agentPackageBusy !== undefined}
|
||||
onClick={() =>
|
||||
void downloadAgentPackage(
|
||||
entry.architecture,
|
||||
entry.platform
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{t(
|
||||
entry.updateAvailable
|
||||
? 'platformFeatures.remoteProjects.agentInventory.updateTo'
|
||||
: entry.state !== 'verified' &&
|
||||
entry.latestVersion !== null
|
||||
? 'platformFeatures.remoteProjects.agentInventory.downloadVersion'
|
||||
: 'platformFeatures.remoteProjects.agentInventory.update',
|
||||
{ version: entry.latestVersion }
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{entry.state === 'verified' && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={agentPackageBusy !== undefined}
|
||||
onClick={() =>
|
||||
void exportAgentPackage(
|
||||
entry.architecture,
|
||||
entry.platform
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Upload aria-hidden="true" size={13} />
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.export'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{agentInventory?.catalog.state === 'available' && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.catalog.available'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{agentInventory?.catalog.state === 'unavailable' && (
|
||||
<p className="settings-warning" role="alert">
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.catalog.unavailable',
|
||||
{ error: agentInventory.catalog.error }
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
@@ -9681,15 +9681,14 @@ describe("App", () => {
|
||||
).toHaveTextContent(remoteProject.name),
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '设置' }));
|
||||
fireEvent.click(await screen.findByRole("tab", { name: "平台功能" }));
|
||||
// The switch now lives in the Hosts application settings.
|
||||
fireEvent.click(screen.getByRole("button", { name: "应用" }));
|
||||
fireEvent.click(await screen.findByRole("menuitem", { name: "管理应用" }));
|
||||
fireEvent.click(
|
||||
await screen.findByRole("tab", {
|
||||
name: "远程项目",
|
||||
}),
|
||||
await screen.findByRole("button", { name: "主机管理 应用设置" }),
|
||||
);
|
||||
const remoteProjectsSwitch = await screen.findByRole("switch", {
|
||||
name: "远程项目",
|
||||
name: "启用远程能力",
|
||||
});
|
||||
expect(remoteProjectsSwitch).toBeChecked();
|
||||
fireEvent.click(remoteProjectsSwitch);
|
||||
@@ -15024,7 +15023,7 @@ describe("App", () => {
|
||||
);
|
||||
await screen.findByRole("heading", { name: "设置中心" });
|
||||
fireEvent.click(screen.getByRole("tab", { name: "平台功能" }));
|
||||
await screen.findByRole("tab", { name: "通用设置" });
|
||||
await screen.findByLabelText("默认工作区目录");
|
||||
expect(screen.queryByRole("tab", { name: "魔法笔记" })).not.toBeInTheDocument();
|
||||
expect(screen.queryByRole("button", { name: "应用设置" })).not.toBeInTheDocument();
|
||||
expect(api.updates.updateSettings).not.toHaveBeenCalled();
|
||||
@@ -15073,24 +15072,24 @@ describe("App", () => {
|
||||
await waitFor(() => expect(move).toBeEnabled())
|
||||
fireEvent.click(move)
|
||||
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
|
||||
'监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享(技术预览)',
|
||||
'监督者', '知识库', '魔法笔记', '本机推理监控', '设备共享(技术预览)', '主机管理',
|
||||
]))
|
||||
await waitFor(() => expect(screen.getByRole('button', { name: '上移 本机推理监控' })).toBeEnabled())
|
||||
const cards = within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article')
|
||||
fireEvent.dragStart(cards[3]!)
|
||||
fireEvent.drop(cards[1]!)
|
||||
await waitFor(() => expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual([
|
||||
'监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)',
|
||||
'监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)', '主机管理',
|
||||
]))
|
||||
expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes', 'device-sharing'])
|
||||
expect((await updates.getSettings()).applicationNavigation.order).toEqual(['heartbeat', 'local-inference', 'knowledge', 'magic-notes', 'device-sharing', 'remote-hosts'])
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭应用中心' }))
|
||||
expect(within(screen.getByRole('navigation', { name: '主导航' })).getAllByRole('button').map(button => button.textContent)).toEqual([
|
||||
'对话', '监督者', '知识库', '魔法笔记', '运行记录',
|
||||
])
|
||||
fireEvent.click(screen.getByRole('button', { name: '应用' }))
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '管理应用'])
|
||||
expect(screen.getAllByRole('menuitem').map(item => item.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '主机管理', '管理应用'])
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '管理应用' }))
|
||||
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)'])
|
||||
expect(within(screen.getByRole('dialog', { name: '应用中心' })).getAllByRole('article').map(card => card.querySelector('strong')?.textContent)).toEqual(['监督者', '本机推理监控', '知识库', '魔法笔记', '设备共享(技术预览)', '主机管理'])
|
||||
})
|
||||
|
||||
it('leaves device sharing on an external settings event and keeps it closed when re-enabled', async () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
ApplicationCenter,
|
||||
applicationDefinitions,
|
||||
isApplicationEnabled,
|
||||
isApplicationOpenable,
|
||||
} from "./ApplicationCenter";
|
||||
import {
|
||||
defaultApplicationNavigation,
|
||||
@@ -287,6 +288,10 @@ const activityPanelRoute = createPreloadableComponent(
|
||||
() => import("./ActivityPanel"),
|
||||
(module) => module.ActivityPanel,
|
||||
);
|
||||
const remoteHostsPageRoute = createPreloadableComponent(
|
||||
() => import("./RemoteHostsPage"),
|
||||
(module) => module.RemoteHostsPage,
|
||||
);
|
||||
const idleRouteModuleLoaders = [heartbeatCenterRoute.preload] as const;
|
||||
|
||||
const KnowledgeWorkspace = knowledgeWorkspaceRoute.Component;
|
||||
@@ -294,6 +299,7 @@ const HeartbeatCenter = heartbeatCenterRoute.Component;
|
||||
const MagicNotesWorkspace = magicNotesWorkspaceRoute.Component;
|
||||
const SettingsPanel = settingsPanelRoute.Component;
|
||||
const ActivityPanel = activityPanelRoute.Component;
|
||||
const RemoteHostsPage = remoteHostsPageRoute.Component;
|
||||
|
||||
const conversationPersistenceIntervalMs = 500;
|
||||
// How often streaming deltas are absorbed into the conversation store; rows render them live.
|
||||
@@ -507,14 +513,14 @@ export function AppNotificationViewport({
|
||||
}
|
||||
|
||||
type WorkspaceView =
|
||||
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "device-sharing" | "activity" | "settings";
|
||||
"chat" | "magic-notes" | "knowledge" | "heartbeat" | "local-inference" | "device-sharing" | "remote-hosts" | "activity" | "settings";
|
||||
|
||||
// Tracks which kept-alive routes currently report unsaved edits. Reads happen
|
||||
// only in event handlers and the sweep timer, so this is not React state.
|
||||
function createUnsavedWorkspaceTracker(): {
|
||||
pinnedViews: () => Set<WorkspaceView>;
|
||||
reporters: Record<
|
||||
"knowledge" | "heartbeat" | "magic-notes" | "activity",
|
||||
"knowledge" | "heartbeat" | "magic-notes" | "remote-hosts" | "activity",
|
||||
ReportWorkspaceUnsavedChanges
|
||||
>;
|
||||
} {
|
||||
@@ -535,6 +541,7 @@ function createUnsavedWorkspaceTracker(): {
|
||||
knowledge: reporterFor("knowledge"),
|
||||
heartbeat: reporterFor("heartbeat"),
|
||||
"magic-notes": reporterFor("magic-notes"),
|
||||
"remote-hosts": reporterFor("remote-hosts"),
|
||||
activity: reporterFor("activity"),
|
||||
},
|
||||
};
|
||||
@@ -547,6 +554,7 @@ const intentRoutePreloaders: Partial<
|
||||
knowledge: knowledgeWorkspaceRoute.preload,
|
||||
activity: activityPanelRoute.preload,
|
||||
settings: settingsPanelRoute.preload,
|
||||
"remote-hosts": remoteHostsPageRoute.preload,
|
||||
};
|
||||
|
||||
function preloadWorkspaceRouteOnIntent(view: WorkspaceView): void {
|
||||
@@ -1515,6 +1523,9 @@ function App(): React.JSX.Element {
|
||||
setActiveIdState(next);
|
||||
}, [activeConversationViewIds, conversationStore]);
|
||||
const [remoteProjectsEnabled, setRemoteProjectsEnabled] = useState(false);
|
||||
const remoteProjectsEnabledRef = useRef(false);
|
||||
// Assigned after the handler is defined below; reads latest projects at call time.
|
||||
const remoteProjectsEnabledChangeRef = useRef<(enabled: boolean) => void>(setRemoteProjectsEnabled);
|
||||
const [
|
||||
conversationHtmlRenderingEnabled,
|
||||
setConversationHtmlRenderingEnabled,
|
||||
@@ -1527,7 +1538,7 @@ function App(): React.JSX.Element {
|
||||
const [incompleteMagicTodoCount, setIncompleteMagicTodoCount] = useState(0);
|
||||
const applicationNavigation = applicationSettings?.applicationNavigation ?? defaultApplicationNavigation;
|
||||
const visibleApplications = useMemo(() => applicationNavigation.order.filter(id =>
|
||||
id === 'knowledge' || (Boolean((applicationNavigation.pinned as Record<string, boolean | undefined>)[id]) && (id === 'magic-notes' ? magicNotesEnabled : isApplicationEnabled(applicationSettings, id)))
|
||||
id === 'knowledge' || (Boolean((applicationNavigation.pinned as Record<string, boolean | undefined>)[id]) && (id === 'magic-notes' ? magicNotesEnabled : isApplicationOpenable(applicationSettings, id)))
|
||||
), [applicationNavigation, applicationSettings, magicNotesEnabled]);
|
||||
const applyApplicationSettings = useCallback((settings: ApplicationSettings): void => {
|
||||
setApplicationSettings(settings);
|
||||
@@ -1549,7 +1560,11 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
setMagicNotesShowIncompleteTodoCount(settings.magicNotesShowIncompleteTodoCount);
|
||||
setConversationHtmlRenderingEnabled(settings.conversationHtmlRenderingEnabled !== false);
|
||||
setRemoteProjectsEnabled(settings.remoteProjectsEnabled);
|
||||
// Whichever surface changed the switch, leave an active SSH project when it turns off.
|
||||
if (remoteProjectsEnabledRef.current !== settings.remoteProjectsEnabled) {
|
||||
remoteProjectsEnabledRef.current = settings.remoteProjectsEnabled;
|
||||
remoteProjectsEnabledChangeRef.current(settings.remoteProjectsEnabled);
|
||||
}
|
||||
}, [guardNoteDraft, setNoteDraft, setView]);
|
||||
const reloadApplicationSettings = useCallback(async (): Promise<void> => {
|
||||
const revision = ++applicationSettingsRevisionRef.current;
|
||||
@@ -4310,9 +4325,15 @@ function App(): React.JSX.Element {
|
||||
}
|
||||
const localProject = projects.find(isOrdinaryLocalProject);
|
||||
if (localProject) {
|
||||
// Keep the Hosts page open when the switch is turned off there.
|
||||
const currentView = viewRef.current;
|
||||
commitProjectSelection(localProject);
|
||||
if (currentView === "remote-hosts") commitView("remote-hosts");
|
||||
}
|
||||
};
|
||||
useLayoutEffect(() => {
|
||||
remoteProjectsEnabledChangeRef.current = handleRemoteProjectsEnabledChange;
|
||||
});
|
||||
|
||||
const archiveProject = async (projectId: string): Promise<void> => {
|
||||
await window.goodbuddy.projects.setArchived(projectId, true);
|
||||
@@ -6535,13 +6556,14 @@ function App(): React.JSX.Element {
|
||||
onExpertsChanged: (experts: AssistantExpert[]) => {
|
||||
setAssistantExperts(experts);
|
||||
},
|
||||
onRemoteProjectsEnabledChange: handleRemoteProjectsEnabledChange,
|
||||
onProjectsDeleted: removeProjectsFromUi,
|
||||
onSaved: (settings: RuntimeSettings) => {
|
||||
setRuntimeSettings(settings);
|
||||
},
|
||||
onUpdateProject: updateProject,
|
||||
});
|
||||
const remoteHostsActions = useStableHandlers({
|
||||
onProjectsDeleted: removeProjectsFromUi,
|
||||
});
|
||||
const rightSidebarActions = useStableHandlers<RightSidebarHandlers>({
|
||||
onBeforeCloseNotes: async () => {
|
||||
if (!await guardNoteDraft()) return false;
|
||||
@@ -7339,6 +7361,38 @@ function App(): React.JSX.Element {
|
||||
</KeepAliveRoute>
|
||||
)}
|
||||
{view === 'device-sharing' && isApplicationEnabled(applicationSettings, 'device-sharing') && <DeviceSharingPage notify={notify} />}
|
||||
{(view === "remote-hosts" ||
|
||||
cachedWorkspaceViewKeys.has("remote-hosts")) && (
|
||||
<KeepAliveRoute
|
||||
active={view === "remote-hosts"}
|
||||
onUnsavedChanges={workspaceUnsavedChangesReporters["remote-hosts"]}
|
||||
route="remote-hosts"
|
||||
>
|
||||
<RouteErrorBoundary
|
||||
key="remote-hosts"
|
||||
fallback={
|
||||
<RouteLoadError
|
||||
message={t("route.loadFailed")}
|
||||
reloadLabel={t("route.reload")}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<Suspense
|
||||
fallback={<RouteLoadingStatus label={t("route.loading")} />}
|
||||
>
|
||||
<RemoteHostsPage
|
||||
active={view === "remote-hosts"}
|
||||
settings={applicationSettings}
|
||||
settingsPending={applicationSettingsPending}
|
||||
settingsLocked={applicationSettingsUnconfirmed}
|
||||
onUpdateSettings={updateApplicationSettings}
|
||||
onNotify={notify}
|
||||
onProjectsDeleted={remoteHostsActions.onProjectsDeleted}
|
||||
/>
|
||||
</Suspense>
|
||||
</RouteErrorBoundary>
|
||||
</KeepAliveRoute>
|
||||
)}
|
||||
{localInferenceOpen && (
|
||||
<LocalInferencePage
|
||||
enabled={isApplicationEnabled(applicationSettings, "local-inference")}
|
||||
@@ -7356,7 +7410,7 @@ function App(): React.JSX.Element {
|
||||
error={applicationSettingsError}
|
||||
onClose={() => setApplicationCenterOpen(false)}
|
||||
onOpen={(id) => {
|
||||
if (!isApplicationEnabled(applicationSettings, id)) return;
|
||||
if (!isApplicationOpenable(applicationSettings, id)) return;
|
||||
setApplicationCenterOpen(false);
|
||||
setView(id);
|
||||
if (window.innerWidth < 900) setSidebarOpen(false);
|
||||
@@ -7410,7 +7464,6 @@ function App(): React.JSX.Element {
|
||||
initialCategory={settingsInitialCategory}
|
||||
initialChannel={settingsInitialChannel}
|
||||
magicNotesEnabled={magicNotesEnabled}
|
||||
remoteProjectsEnabled={remoteProjectsEnabled}
|
||||
onConversationHtmlRenderingEnabledChange={
|
||||
setConversationHtmlRenderingEnabled
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
ApplicationCenter,
|
||||
ApplicationSettingsView,
|
||||
isApplicationEnabled,
|
||||
isApplicationOpenable,
|
||||
} from './ApplicationCenter'
|
||||
|
||||
const settings = applicationSettingsSchema.parse({
|
||||
@@ -39,7 +40,7 @@ const pinnedSettings = {
|
||||
...settings,
|
||||
applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': true },
|
||||
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': true, 'remote-hosts': false },
|
||||
},
|
||||
}
|
||||
afterEach(async () => {
|
||||
@@ -73,6 +74,35 @@ describe('Application Center', () => {
|
||||
expect(handlers.onOpen).toHaveBeenCalledWith('device-sharing')
|
||||
})
|
||||
|
||||
it('opens Hosts while remote capability is off and switches it through the shared remote projects setting', () => {
|
||||
const handlers = props()
|
||||
const { rerender } = render(<ApplicationCenter {...handlers} />)
|
||||
expect(isApplicationEnabled(settings, 'remote-hosts')).toBe(false)
|
||||
expect(isApplicationOpenable(settings, 'remote-hosts')).toBe(true)
|
||||
expect(isApplicationOpenable(undefined, 'remote-hosts')).toBe(false)
|
||||
const card = screen.getByText('主机管理', { exact: true }).closest('article')!
|
||||
expect(within(card).getByText('可选 · 已关闭')).toBeVisible()
|
||||
fireEvent.click(within(card).getByRole('button', { name: '打开' }))
|
||||
expect(handlers.onOpen).toHaveBeenCalledWith('remote-hosts')
|
||||
fireEvent.click(within(card).getByRole('button', { name: '主机管理 应用设置' }))
|
||||
const enable = screen.getByRole('switch', { name: '启用远程能力' })
|
||||
expect(enable).not.toBeChecked()
|
||||
expect(screen.getByText(/主机、凭据、远程项目和 Agent 离线包都会保留/)).toBeVisible()
|
||||
fireEvent.click(enable)
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ remoteProjectsEnabled: true })
|
||||
rerender(<ApplicationCenter {...handlers} settings={{ ...settings, remoteProjectsEnabled: true }} />)
|
||||
expect(enable).toBeChecked()
|
||||
const pin = screen.getByRole('switch', { name: '常驻左侧菜单' })
|
||||
expect(pin).not.toBeChecked()
|
||||
fireEvent.click(pin)
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
|
||||
applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
pinned: { ...defaultApplicationNavigation.pinned, 'remote-hosts': true },
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
it.each(['zh-CN', 'en-US'] as const)('keeps disable consequences visible and pin help separate in %s', async (locale) => {
|
||||
await changeUiLocale(locale)
|
||||
const onUpdate = vi.fn(async () => true)
|
||||
@@ -127,7 +157,7 @@ describe('Application Center', () => {
|
||||
fireEvent.drop(card)
|
||||
expect(handlers.onUpdate).toHaveBeenCalledWith({ applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing'],
|
||||
order: ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing', 'remote-hosts'],
|
||||
} })
|
||||
})
|
||||
|
||||
@@ -239,7 +269,7 @@ describe('Application Center', () => {
|
||||
fireEvent.dragStart(notes)
|
||||
fireEvent.drop(inference)
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: {
|
||||
...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
|
||||
...defaultApplicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'],
|
||||
} })
|
||||
fireEvent.click(within(inference).getByRole('button', { name: '打开' }))
|
||||
expect(handlers.onOpen).not.toHaveBeenCalled()
|
||||
@@ -263,9 +293,9 @@ describe('Application Center', () => {
|
||||
const handlers = props()
|
||||
const { rerender } = render(<ApplicationCenter {...handlers} settings={pinnedSettings} />)
|
||||
const displayedOrder = () => screen.getAllByRole('article').map((row) => row.querySelector('strong')?.textContent)
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)'])
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)', '主机管理'])
|
||||
expect(screen.getByRole('button', { name: '上移 知识库' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '下移 主机管理' })).toBeDisabled()
|
||||
const moveUp = screen.getByRole('button', { name: '上移 本机推理监控' })
|
||||
expect(moveUp.closest('article')).not.toBeNull()
|
||||
expect(moveUp).toHaveAttribute('title', '上移 本机推理监控')
|
||||
@@ -273,18 +303,18 @@ describe('Application Center', () => {
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({
|
||||
applicationNavigation: {
|
||||
...pinnedSettings.applicationNavigation,
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'],
|
||||
},
|
||||
})
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
|
||||
expect(screen.getByRole('button', { name: '下移 主机管理' })).toBeDisabled()
|
||||
expect(await screen.findByRole('status')).not.toBeEmptyDOMElement()
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)'])
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '魔法笔记', '本机推理监控', '设备共享(技术预览)', '主机管理'])
|
||||
rerender(<ApplicationCenter {...handlers} settings={{
|
||||
...pinnedSettings,
|
||||
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'] },
|
||||
applicationNavigation: { ...pinnedSettings.applicationNavigation, order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'] },
|
||||
}} />)
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享(技术预览)'])
|
||||
expect(screen.getByRole('button', { name: '下移 设备共享(技术预览)' })).toBeDisabled()
|
||||
expect(displayedOrder()).toEqual(['知识库', '监督者', '本机推理监控', '魔法笔记', '设备共享(技术预览)', '主机管理'])
|
||||
expect(screen.getByRole('button', { name: '下移 主机管理' })).toBeDisabled()
|
||||
fireEvent.click(screen.getByRole('button', { name: '下移 本机推理监控' }))
|
||||
expect(handlers.onUpdate).toHaveBeenLastCalledWith({ applicationNavigation: pinnedSettings.applicationNavigation })
|
||||
fireEvent.click(screen.getByRole('button', { name: '本机推理监控 应用设置' }))
|
||||
@@ -306,7 +336,7 @@ describe('Application Center', () => {
|
||||
expect(handlers.onUpdate).toHaveBeenCalledWith({
|
||||
applicationNavigation: {
|
||||
...pinnedSettings.applicationNavigation,
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'],
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -352,7 +382,7 @@ describe('Application Center', () => {
|
||||
expect(handlers.onUpdate).toHaveBeenCalledWith({
|
||||
applicationNavigation: {
|
||||
...pinnedSettings.applicationNavigation,
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing'],
|
||||
order: ['knowledge', 'heartbeat', 'local-inference', 'magic-notes', 'device-sharing', 'remote-hosts'],
|
||||
},
|
||||
})
|
||||
})
|
||||
@@ -367,7 +397,7 @@ describe('Application Center', () => {
|
||||
})
|
||||
expect(screen.getByText('未找到相关应用')).toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('button', { name: '清除搜索' }))
|
||||
expect(screen.getAllByRole('article')).toHaveLength(5)
|
||||
expect(screen.getAllByRole('article')).toHaveLength(6)
|
||||
})
|
||||
|
||||
it('edits all notes preferences through the shared settings form', () => {
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
Network,
|
||||
HeartPulse,
|
||||
Library,
|
||||
Server,
|
||||
Settings,
|
||||
Sparkles,
|
||||
X,
|
||||
@@ -52,6 +53,12 @@ export const applicationDefinitions = {
|
||||
title: 'applications.localInference',
|
||||
enabled: 'localInferenceEnabled',
|
||||
},
|
||||
// Shares the remote projects switch: the backend gates hosts on it.
|
||||
'remote-hosts': {
|
||||
icon: Server,
|
||||
title: 'applications.remoteHosts',
|
||||
enabled: 'remoteProjectsEnabled',
|
||||
},
|
||||
} as const
|
||||
|
||||
export function isApplicationEnabled(
|
||||
@@ -60,6 +67,7 @@ export function isApplicationEnabled(
|
||||
): boolean {
|
||||
if (id === 'knowledge') return true
|
||||
if (id === 'device-sharing') return settings?.deviceSharingEnabled === true
|
||||
if (id === 'remote-hosts') return settings?.remoteProjectsEnabled === true
|
||||
if (id === 'heartbeat') return settings?.heartbeatEnabled === true
|
||||
return (
|
||||
settings !== undefined &&
|
||||
@@ -67,6 +75,17 @@ export function isApplicationEnabled(
|
||||
)
|
||||
}
|
||||
|
||||
// Hosts stays openable while remote capability is off: Agent packages can be
|
||||
// prepared ahead of time, and the page offers the switch itself.
|
||||
export function isApplicationOpenable(
|
||||
settings: ApplicationSettings | undefined,
|
||||
id: BuiltInApplicationId,
|
||||
): boolean {
|
||||
return id === 'remote-hosts'
|
||||
? settings !== undefined
|
||||
: isApplicationEnabled(settings, id)
|
||||
}
|
||||
|
||||
export function ApplicationAvailability({
|
||||
enabled,
|
||||
children,
|
||||
@@ -120,7 +139,7 @@ export function ApplicationSettingsView({
|
||||
})
|
||||
}
|
||||
/>
|
||||
<span>{t('applications.enable')}</span>
|
||||
<span>{t(id === 'remote-hosts' ? 'applications.remoteHostsEnable' : 'applications.enable')}</span>
|
||||
</label>
|
||||
{id !== 'device-sharing' && <div className="inline-help-label">
|
||||
<label className="toggle-row">
|
||||
@@ -143,7 +162,7 @@ export function ApplicationSettingsView({
|
||||
</label>
|
||||
<InlineHelp label={t('applications.pin')} id="application-pin-help">{t('applications.pinHelp')}</InlineHelp>
|
||||
</div>}
|
||||
<p className="settings-notice">{t(id === 'device-sharing' ? 'applications.deviceSharingHelp' : 'applications.disableHelp')}</p>
|
||||
<p className="settings-notice">{t(id === 'device-sharing' ? 'applications.deviceSharingHelp' : id === 'remote-hosts' ? 'applications.remoteHostsHelp' : 'applications.disableHelp')}</p>
|
||||
{id === 'magic-notes' && (
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
@@ -464,7 +483,7 @@ export function ApplicationCenter({
|
||||
<button
|
||||
className="secondary-button"
|
||||
type="button"
|
||||
disabled={!enabled}
|
||||
disabled={!isApplicationOpenable(settings, id)}
|
||||
onClick={() => onOpen(id)}
|
||||
>
|
||||
{t('applications.open')}
|
||||
|
||||
@@ -35,10 +35,20 @@ it('only shows device sharing when explicitly enabled and removes it on disable'
|
||||
expect(screen.queryByRole('menuitem', { name: '设备共享(技术预览)' })).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists Hosts once settings load, even while remote capability is off', () => {
|
||||
const onOpen = vi.fn()
|
||||
const { rerender } = render(<Harness value={null} pending onOpen={onOpen} />)
|
||||
fireEvent.click(screen.getByText('Launcher'))
|
||||
expect(screen.queryByRole('menuitem', { name: '主机管理' })).not.toBeInTheDocument()
|
||||
rerender(<Harness onOpen={onOpen} />)
|
||||
fireEvent.click(screen.getByRole('menuitem', { name: '主机管理' }))
|
||||
expect(onOpen).toHaveBeenCalledWith('remote-hosts')
|
||||
})
|
||||
|
||||
it('filters the shared order by enablement only, including reordered always-shown apps', () => {
|
||||
const value = { ...settings, magicNotesEnabled: true, localInferenceEnabled: true, heartbeatEnabled: true,
|
||||
applicationNavigation: { order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge'] as ApplicationSettings['applicationNavigation']['order'],
|
||||
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false } } }
|
||||
pinned: { 'magic-notes': false, heartbeat: true, 'local-inference': false, 'remote-hosts': false } } }
|
||||
const onOpen = vi.fn()
|
||||
const { rerender } = render(<Harness value={value} onOpen={onOpen} />)
|
||||
fireEvent.click(screen.getByText('Launcher'))
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useMemo, type RefObject } from 'react'
|
||||
import { LayoutGrid } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { defaultApplicationNavigation, type ApplicationSettings, type BuiltInApplicationId } from '../../shared/application-settings-contracts'
|
||||
import { applicationDefinitions, isApplicationEnabled } from './ApplicationCenter'
|
||||
import { applicationDefinitions, isApplicationOpenable } from './ApplicationCenter'
|
||||
import { AnchoredMenu } from './AnchoredMenu'
|
||||
|
||||
export function ApplicationMenu({ anchorRef, settings, pending, error, onClose, onOpen, onManage, onRetry }: {
|
||||
@@ -19,7 +19,7 @@ export function ApplicationMenu({ anchorRef, settings, pending, error, onClose,
|
||||
const applications = useMemo(() => {
|
||||
const order = (settings?.applicationNavigation ?? defaultApplicationNavigation).order
|
||||
return order
|
||||
.filter(id => isApplicationEnabled(settings, id))
|
||||
.filter(id => isApplicationOpenable(settings, id))
|
||||
}, [settings])
|
||||
return <AnchoredMenu anchorRef={anchorRef} id="application-menu" label={t('applications.title')} onClose={onClose}>
|
||||
{pending && <p role="status">{t('route.loading')}</p>}
|
||||
|
||||
@@ -1,16 +1,10 @@
|
||||
import {
|
||||
Cpu,
|
||||
Download,
|
||||
FolderOpen,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Save,
|
||||
Upload
|
||||
Save
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
@@ -18,13 +12,6 @@ import { InlineHelp } from './InlineHelp'
|
||||
import type {
|
||||
ApplicationSettings
|
||||
} from '../../shared/application-settings-contracts'
|
||||
import type {
|
||||
AgentPackageDownloadProgress,
|
||||
AgentPackageInventory
|
||||
} from '../../shared/agent-package-contracts'
|
||||
import type {
|
||||
AgentArchitecture
|
||||
} from '../../shared/agent-installation-contracts'
|
||||
import {
|
||||
canonicalizeShortcutAccelerator,
|
||||
type GlobalShortcutSettings,
|
||||
@@ -32,7 +19,6 @@ import {
|
||||
type GlobalShortcutUpdateErrorCode
|
||||
} from '../../shared/shortcut'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import { PageTabs } from './WorkspacePrimitives'
|
||||
import {
|
||||
SettingsCategoryHeader,
|
||||
SettingsWarningList
|
||||
@@ -43,7 +29,6 @@ type PlatformFeaturesSettingsSectionProps = {
|
||||
onConversationHtmlRenderingEnabledChange: (
|
||||
enabled: boolean
|
||||
) => void
|
||||
onRemoteProjectsEnabledChange: (enabled: boolean) => void
|
||||
onWorkspacePathChange: (path: string) => void
|
||||
workspacePath: string
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
@@ -53,8 +38,6 @@ type PlatformFeaturesSettingsSectionProps = {
|
||||
) => void
|
||||
}
|
||||
|
||||
type PlatformFeaturesTab = 'general' | 'remote-projects'
|
||||
|
||||
const shortcutErrorTranslationKeys: Record<
|
||||
GlobalShortcutUpdateErrorCode,
|
||||
| 'platformFeatures.shortcut.errors.conflict'
|
||||
@@ -69,7 +52,6 @@ const shortcutErrorTranslationKeys: Record<
|
||||
|
||||
export function PlatformFeaturesSettingsSection({
|
||||
onConversationHtmlRenderingEnabledChange,
|
||||
onRemoteProjectsEnabledChange,
|
||||
onWorkspacePathChange,
|
||||
workspacePath,
|
||||
onNotify,
|
||||
@@ -78,8 +60,6 @@ export function PlatformFeaturesSettingsSection({
|
||||
}: PlatformFeaturesSettingsSectionProps): React.JSX.Element {
|
||||
const { t } = useTranslation('settingsSections')
|
||||
const { t: settingsT } = useTranslation('settings')
|
||||
const [activeSection, setActiveSection] =
|
||||
useState<PlatformFeaturesTab>('general')
|
||||
const [settings, setSettings] = useState<ApplicationSettings>()
|
||||
const [shortcutSnapshot, setShortcutSnapshot] =
|
||||
useState<GlobalShortcutSettingsSnapshot>()
|
||||
@@ -93,17 +73,6 @@ export function PlatformFeaturesSettingsSection({
|
||||
: t('platformFeatures.shortcut.errors.serviceUnavailable')
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [agentInventory, setAgentInventory] =
|
||||
useState<AgentPackageInventory>()
|
||||
const [agentInventoryLoading, setAgentInventoryLoading] =
|
||||
useState(false)
|
||||
const [agentInventoryError, setAgentInventoryError] =
|
||||
useState<string>()
|
||||
const [agentPackageBusy, setAgentPackageBusy] =
|
||||
useState<string>()
|
||||
const [agentPackageProgress, setAgentPackageProgress] =
|
||||
useState<AgentPackageDownloadProgress>()
|
||||
const agentInventoryRequested = useRef(false)
|
||||
const [error, setError] = useState<string | undefined>(() =>
|
||||
window.goodbuddy.updates
|
||||
? undefined
|
||||
@@ -135,162 +104,6 @@ export function PlatformFeaturesSettingsSection({
|
||||
}
|
||||
}, [t])
|
||||
|
||||
const loadAgentInventory = useCallback(async (
|
||||
refresh = false
|
||||
): Promise<void> => {
|
||||
const getInventory =
|
||||
window.goodbuddy.sshHosts?.getAgentPackageInventory
|
||||
if (!getInventory) {
|
||||
setAgentInventoryError(
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.unavailable'
|
||||
)
|
||||
)
|
||||
return
|
||||
}
|
||||
setAgentInventoryLoading(true)
|
||||
setAgentInventoryError(undefined)
|
||||
try {
|
||||
setAgentInventory(await getInventory(refresh))
|
||||
} catch (reason) {
|
||||
setAgentInventoryError(
|
||||
displayErrorMessage(
|
||||
reason,
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.readFailed'
|
||||
)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setAgentInventoryLoading(false)
|
||||
}
|
||||
}, [t])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
activeSection !== 'remote-projects' ||
|
||||
agentInventoryRequested.current
|
||||
) {
|
||||
return
|
||||
}
|
||||
agentInventoryRequested.current = true
|
||||
void loadAgentInventory()
|
||||
}, [activeSection, loadAgentInventory])
|
||||
|
||||
useEffect(() => {
|
||||
const subscribe =
|
||||
window.goodbuddy.sshHosts?.onAgentPackageProgress
|
||||
return subscribe?.((progress) =>
|
||||
setAgentPackageProgress(progress)
|
||||
)
|
||||
}, [])
|
||||
|
||||
const downloadAgentPackage = async (
|
||||
architecture: AgentArchitecture,
|
||||
platform: 'linux' | 'darwin'
|
||||
): Promise<void> => {
|
||||
const download =
|
||||
window.goodbuddy.sshHosts?.downloadAgentPackage
|
||||
if (!download) {
|
||||
return
|
||||
}
|
||||
setAgentPackageBusy(`${platform}-${architecture}`)
|
||||
setAgentPackageProgress(undefined)
|
||||
setAgentInventoryError(undefined)
|
||||
try {
|
||||
setAgentInventory(await download(architecture, platform))
|
||||
onNotify?.({
|
||||
dedupeKey: `agent-package-downloaded:${platform}-${architecture}`,
|
||||
message: t(
|
||||
'platformFeatures.remoteProjects.agentInventory.notifications.downloaded',
|
||||
{ architecture, platform: platform === 'darwin' ? 'macOS' : 'Linux' }
|
||||
),
|
||||
tone: 'success'
|
||||
})
|
||||
} catch (reason) {
|
||||
setAgentInventoryError(
|
||||
displayErrorMessage(
|
||||
reason,
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.downloadFailed'
|
||||
)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setAgentPackageBusy(undefined)
|
||||
setAgentPackageProgress(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const importAgentPackage = async (): Promise<void> => {
|
||||
const importPackage =
|
||||
window.goodbuddy.sshHosts?.importAgentPackage
|
||||
if (!importPackage) {
|
||||
return
|
||||
}
|
||||
setAgentPackageBusy('import')
|
||||
setAgentInventoryError(undefined)
|
||||
try {
|
||||
const inventory = await importPackage()
|
||||
if (inventory) {
|
||||
setAgentInventory(inventory)
|
||||
onNotify?.({
|
||||
dedupeKey: 'agent-package-imported',
|
||||
message: t(
|
||||
'platformFeatures.remoteProjects.agentInventory.notifications.imported'
|
||||
),
|
||||
tone: 'success'
|
||||
})
|
||||
}
|
||||
} catch (reason) {
|
||||
setAgentInventoryError(
|
||||
displayErrorMessage(
|
||||
reason,
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.importFailed'
|
||||
)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setAgentPackageBusy(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const exportAgentPackage = async (
|
||||
architecture: AgentArchitecture,
|
||||
platform: 'linux' | 'darwin'
|
||||
): Promise<void> => {
|
||||
const exportPackage =
|
||||
window.goodbuddy.sshHosts?.exportAgentPackage
|
||||
if (!exportPackage) {
|
||||
return
|
||||
}
|
||||
setAgentPackageBusy(`${platform}-${architecture}`)
|
||||
setAgentInventoryError(undefined)
|
||||
try {
|
||||
await exportPackage(architecture, platform)
|
||||
onNotify?.({
|
||||
dedupeKey: `agent-package-exported:${platform}-${architecture}`,
|
||||
message: t(
|
||||
'platformFeatures.remoteProjects.agentInventory.notifications.exported',
|
||||
{ architecture, platform: platform === 'darwin' ? 'macOS' : 'Linux' }
|
||||
),
|
||||
tone: 'success'
|
||||
})
|
||||
} catch (reason) {
|
||||
setAgentInventoryError(
|
||||
displayErrorMessage(
|
||||
reason,
|
||||
t(
|
||||
'platformFeatures.remoteProjects.agentInventory.errors.exportFailed'
|
||||
)
|
||||
)
|
||||
)
|
||||
} finally {
|
||||
setAgentPackageBusy(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
const shortcuts = window.goodbuddy.shortcuts
|
||||
let active = true
|
||||
@@ -475,69 +288,20 @@ export function PlatformFeaturesSettingsSection({
|
||||
}
|
||||
}
|
||||
|
||||
const changeRemoteProjects = async (
|
||||
enabled: boolean
|
||||
): Promise<void> => {
|
||||
const updates = window.goodbuddy.updates
|
||||
if (!updates || !settings) {
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
setError(undefined)
|
||||
try {
|
||||
const nextSettings = await updates.updateSettings({
|
||||
remoteProjectsEnabled: enabled
|
||||
})
|
||||
setSettings(nextSettings)
|
||||
onRemoteProjectsEnabledChange(
|
||||
nextSettings.remoteProjectsEnabled
|
||||
)
|
||||
} catch {
|
||||
setError(
|
||||
t('platformFeatures.errors.saveRemoteProjectsFailed')
|
||||
)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsCategoryHeader
|
||||
category="platform-features"
|
||||
error={error}
|
||||
headingId="platform-features-heading"
|
||||
navigation={
|
||||
<div className="platform-features-tabs">
|
||||
<PageTabs
|
||||
ariaLabel={t('platformFeatures.tabs.ariaLabel')}
|
||||
idPrefix="platform-features"
|
||||
onChange={setActiveSection}
|
||||
tabs={[
|
||||
{
|
||||
id: 'general',
|
||||
label: t('platformFeatures.tabs.general')
|
||||
},
|
||||
{
|
||||
id: 'remote-projects',
|
||||
label: t('platformFeatures.remoteProjects.title')
|
||||
}
|
||||
]}
|
||||
value={activeSection}
|
||||
variant="segmented"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<SettingsWarningList warnings={settings?.warnings} />
|
||||
|
||||
{activeSection === 'general' && (
|
||||
<section
|
||||
aria-labelledby="platform-features-tab-general"
|
||||
className="settings-section"
|
||||
id="platform-features-panel-general"
|
||||
role="tabpanel"
|
||||
>
|
||||
<section
|
||||
aria-labelledby="platform-features-heading"
|
||||
className="settings-section"
|
||||
id="platform-features-panel-general"
|
||||
>
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<span className="inline-help-label">
|
||||
@@ -740,304 +504,7 @@ export function PlatformFeaturesSettingsSection({
|
||||
</div>
|
||||
</label>
|
||||
</article>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{activeSection === 'remote-projects' && (
|
||||
<section
|
||||
aria-labelledby="platform-features-tab-remote-projects"
|
||||
className="settings-section"
|
||||
id="platform-features-panel-remote-projects"
|
||||
role="tabpanel"
|
||||
>
|
||||
{settings ? (
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<span className="inline-help-label">
|
||||
<strong>
|
||||
{t('platformFeatures.remoteProjects.title')}
|
||||
</strong>
|
||||
<InlineHelp label={t('platformFeatures.remoteProjects.title')}>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.description'
|
||||
)}
|
||||
</InlineHelp>
|
||||
</span>
|
||||
</div>
|
||||
<label className="toggle-row">
|
||||
<input
|
||||
aria-label={t(
|
||||
'platformFeatures.remoteProjects.title'
|
||||
)}
|
||||
checked={settings.remoteProjectsEnabled}
|
||||
disabled={saving}
|
||||
onChange={(event) =>
|
||||
void changeRemoteProjects(event.target.checked)
|
||||
}
|
||||
role="switch"
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
{t('platformFeatures.remoteProjects.enabled')}
|
||||
</span>
|
||||
</label>
|
||||
</article>
|
||||
) : (
|
||||
!error && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t('platformFeatures.loading')}
|
||||
</p>
|
||||
)
|
||||
)}
|
||||
<article className="capability-card">
|
||||
<div className="capability-card__header">
|
||||
<div>
|
||||
<strong>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.title'
|
||||
)}
|
||||
</strong>
|
||||
<small>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.description'
|
||||
)}
|
||||
</small>
|
||||
</div>
|
||||
<div className="capability-card__actions">
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={agentPackageBusy !== undefined}
|
||||
onClick={() => void importAgentPackage()}
|
||||
type="button"
|
||||
>
|
||||
<Upload aria-hidden="true" size={13} />
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.import'
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
aria-label={t(
|
||||
'platformFeatures.remoteProjects.agentInventory.refresh'
|
||||
)}
|
||||
className="secondary-button"
|
||||
disabled={
|
||||
agentInventoryLoading ||
|
||||
agentPackageBusy !== undefined
|
||||
}
|
||||
onClick={() => void loadAgentInventory(true)}
|
||||
type="button"
|
||||
>
|
||||
<RefreshCw
|
||||
aria-hidden="true"
|
||||
className={
|
||||
agentInventoryLoading
|
||||
? 'bundled-agent-inventory__spinner'
|
||||
: undefined
|
||||
}
|
||||
size={13}
|
||||
/>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.refresh'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{agentInventory && (
|
||||
<p className="settings-notice">
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.summary',
|
||||
{
|
||||
available: agentInventory.entries.filter(
|
||||
(entry) => entry.state === 'verified'
|
||||
).length,
|
||||
total: agentInventory.entries.length
|
||||
}
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{agentInventoryLoading && !agentInventory ? (
|
||||
<p className="settings-notice" role="status">
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.loading'
|
||||
)}
|
||||
</p>
|
||||
) : agentInventoryError ? (
|
||||
<p className="settings-warning" role="alert">
|
||||
{agentInventoryError}
|
||||
</p>
|
||||
) : agentInventory ? (
|
||||
<div
|
||||
aria-label={t(
|
||||
'platformFeatures.remoteProjects.agentInventory.listLabel'
|
||||
)}
|
||||
className="bundled-agent-inventory"
|
||||
role="list"
|
||||
>
|
||||
{agentInventory.entries.map((entry) => (
|
||||
<article
|
||||
className="bundled-agent-inventory__item"
|
||||
key={`${entry.platform}-${entry.architecture}`}
|
||||
role="listitem"
|
||||
>
|
||||
<div className="bundled-agent-inventory__item-header">
|
||||
<Cpu aria-hidden="true" size={16} />
|
||||
<strong>
|
||||
{entry.platform === 'darwin' ? 'macOS' : 'Linux'} {entry.architecture}
|
||||
</strong>
|
||||
<span
|
||||
className={`bundled-agent-inventory__badge bundled-agent-inventory__badge--${
|
||||
entry.updateAvailable
|
||||
? 'update-available'
|
||||
: agentInventory.catalog.state ===
|
||||
'available' &&
|
||||
entry.state === 'verified' &&
|
||||
entry.latestVersion !== null
|
||||
? 'up-to-date'
|
||||
: entry.state
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
entry.updateAvailable
|
||||
? 'platformFeatures.remoteProjects.agentInventory.states.updateAvailable'
|
||||
: agentInventory.catalog.state ===
|
||||
'available' &&
|
||||
entry.state === 'verified' &&
|
||||
entry.latestVersion !== null
|
||||
? 'platformFeatures.remoteProjects.agentInventory.states.upToDate'
|
||||
: `platformFeatures.remoteProjects.agentInventory.states.${entry.state}`
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.agentVersion'
|
||||
)}
|
||||
</dt>
|
||||
<dd>{entry.version ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.latestVersion'
|
||||
)}
|
||||
</dt>
|
||||
<dd>{entry.latestVersion ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.architecture'
|
||||
)}
|
||||
</dt>
|
||||
<dd>{entry.architecture}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.runtimeVersion'
|
||||
)}
|
||||
</dt>
|
||||
<dd>{entry.remoteRuntimeVersion ?? '—'}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.fields.protocol'
|
||||
)}
|
||||
</dt>
|
||||
<dd>
|
||||
{entry.agentProtocol
|
||||
? `${entry.agentProtocol.major}.${entry.agentProtocol.minor}`
|
||||
: '—'}
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
{agentPackageProgress?.architecture ===
|
||||
entry.architecture &&
|
||||
agentPackageProgress.platform === entry.platform &&
|
||||
agentPackageBusy === `${entry.platform}-${entry.architecture}` && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t(
|
||||
`platformFeatures.remoteProjects.agentInventory.progress.${agentPackageProgress.phase}`
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<div className="capability-card__actions">
|
||||
{(
|
||||
entry.updateAvailable ||
|
||||
(
|
||||
entry.state !== 'verified' &&
|
||||
entry.latestVersion !== null
|
||||
) ||
|
||||
agentInventory.catalog.state !== 'available'
|
||||
) && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={agentPackageBusy !== undefined}
|
||||
onClick={() =>
|
||||
void downloadAgentPackage(
|
||||
entry.architecture,
|
||||
entry.platform
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Download aria-hidden="true" size={13} />
|
||||
{t(
|
||||
entry.updateAvailable
|
||||
? 'platformFeatures.remoteProjects.agentInventory.updateTo'
|
||||
: entry.state !== 'verified' &&
|
||||
entry.latestVersion !== null
|
||||
? 'platformFeatures.remoteProjects.agentInventory.downloadVersion'
|
||||
: 'platformFeatures.remoteProjects.agentInventory.update',
|
||||
{ version: entry.latestVersion }
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
{entry.state === 'verified' && (
|
||||
<button
|
||||
className="secondary-button"
|
||||
disabled={agentPackageBusy !== undefined}
|
||||
onClick={() =>
|
||||
void exportAgentPackage(
|
||||
entry.architecture,
|
||||
entry.platform
|
||||
)
|
||||
}
|
||||
type="button"
|
||||
>
|
||||
<Upload aria-hidden="true" size={13} />
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.export'
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{agentInventory?.catalog.state === 'available' && (
|
||||
<p className="settings-notice" role="status">
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.catalog.available'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
{agentInventory?.catalog.state === 'unavailable' && (
|
||||
<p className="settings-warning" role="alert">
|
||||
{t(
|
||||
'platformFeatures.remoteProjects.agentInventory.catalog.unavailable',
|
||||
{ error: agentInventory.catalog.error }
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</article>
|
||||
</section>
|
||||
)}
|
||||
|
||||
</section>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -382,8 +382,7 @@ const heartbeatSettingsProps = {
|
||||
onUpdateProject: vi.fn(async () => {
|
||||
throw new Error('Project update is not used in this test')
|
||||
}),
|
||||
projects: [],
|
||||
remoteProjectsEnabled: true
|
||||
projects: []
|
||||
}
|
||||
const assistantExpert: AssistantExpert = {
|
||||
id: '00000000-0000-4000-8000-000000000101',
|
||||
@@ -2035,12 +2034,37 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(within(navigation).getAllByRole('tab').map(tab => tab.id)).toEqual([
|
||||
'settings-tab-appearance', 'settings-tab-platform-features',
|
||||
'settings-tab-model', 'settings-tab-context-control', 'settings-tab-runtime',
|
||||
'settings-tab-ssh-hosts',
|
||||
'settings-tab-document-parsing', 'settings-tab-channels', 'settings-tab-roles',
|
||||
'settings-tab-capabilities', 'settings-tab-about'
|
||||
])
|
||||
})
|
||||
|
||||
it('keeps hosts, Agent packages, and the remote switch out of settings', async () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: '主机与远程执行' })
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
await screen.findByLabelText('默认工作区目录')
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: '远程项目' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('switch', { name: '远程项目' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('GoodBuddy Agent 包')).not.toBeInTheDocument()
|
||||
expect(getAgentPackageInventory).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('omits the redundant close-only footer on passive settings pages', () => {
|
||||
render(
|
||||
<SettingsPanel
|
||||
@@ -2356,304 +2380,6 @@ describe('SettingsPanel runtime files', () => {
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('persists Remote Projects from its own tab and notifies its parent', async () => {
|
||||
const onRemoteProjectsEnabledChange = vi.fn()
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
onRemoteProjectsEnabledChange={
|
||||
onRemoteProjectsEnabledChange
|
||||
}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '远程项目' })
|
||||
)
|
||||
const remoteProjectsSwitch = await screen.findByRole('switch', {
|
||||
name: '远程项目'
|
||||
})
|
||||
expect(remoteProjectsSwitch).not.toBeChecked()
|
||||
expect(
|
||||
await screen.findByText('GoodBuddy Agent 包')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByText('当前 1 / 2 个平台与架构目标可用。')
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Linux x64')).toBeInTheDocument()
|
||||
expect(screen.getByText('Linux arm64')).toBeInTheDocument()
|
||||
expect(screen.getByText('有更新')).toBeInTheDocument()
|
||||
expect(screen.getByText('未下载')).toBeInTheDocument()
|
||||
expect(getAgentPackageInventory).toHaveBeenCalledOnce()
|
||||
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(1, false)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '刷新包清单' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(getAgentPackageInventory).toHaveBeenCalledTimes(2)
|
||||
)
|
||||
expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, true)
|
||||
|
||||
const x64Card = screen
|
||||
.getByText('Linux x64')
|
||||
.closest('[role="listitem"]')
|
||||
const arm64Card = screen
|
||||
.getByText('Linux arm64')
|
||||
.closest('[role="listitem"]')
|
||||
expect(x64Card).not.toBeNull()
|
||||
expect(arm64Card).not.toBeNull()
|
||||
downloadAgentPackage.mockResolvedValueOnce({
|
||||
...agentPackageInventory,
|
||||
entries: agentPackageInventory.entries.map((entry) =>
|
||||
entry.architecture === 'x64'
|
||||
? {
|
||||
...entry,
|
||||
version: '0.11.4',
|
||||
updateAvailable: false
|
||||
}
|
||||
: entry
|
||||
)
|
||||
})
|
||||
fireEvent.click(
|
||||
within(x64Card as HTMLElement).getByRole('button', {
|
||||
name: '更新到 0.11.4'
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(downloadAgentPackage).toHaveBeenCalledWith('x64', 'linux')
|
||||
)
|
||||
expect(
|
||||
await within(x64Card as HTMLElement).findByText('已是最新')
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(x64Card as HTMLElement).queryByRole('button', {
|
||||
name: /更新到/u
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
within(x64Card as HTMLElement).getByRole('button', {
|
||||
name: '导出离线包'
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(exportAgentPackage).toHaveBeenCalledWith('x64', 'linux')
|
||||
)
|
||||
fireEvent.click(
|
||||
within(arm64Card as HTMLElement).getByRole('button', {
|
||||
name: '下载 0.11.4'
|
||||
})
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(downloadAgentPackage).toHaveBeenCalledWith('arm64', 'linux')
|
||||
)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '导入离线包' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(importAgentPackage).toHaveBeenCalledOnce()
|
||||
)
|
||||
|
||||
fireEvent.click(remoteProjectsSwitch)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(updateApplicationSettings).toHaveBeenCalledWith({
|
||||
remoteProjectsEnabled: true
|
||||
})
|
||||
)
|
||||
expect(onRemoteProjectsEnabledChange).toHaveBeenCalledWith(true)
|
||||
expect(remoteProjectsSwitch).toBeChecked()
|
||||
})
|
||||
|
||||
it('shows Agent package loading and recovers from a refresh error', async () => {
|
||||
let resolveInventory:
|
||||
| ((inventory: AgentPackageInventory) => void)
|
||||
| undefined
|
||||
getAgentPackageInventory.mockReturnValueOnce(
|
||||
new Promise<AgentPackageInventory>((resolve) => {
|
||||
resolveInventory = resolve
|
||||
})
|
||||
)
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '远程项目' })
|
||||
)
|
||||
expect(
|
||||
await screen.findByText(
|
||||
'正在校验本地 Agent 包并检查在线版本…'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
|
||||
await act(async () => {
|
||||
resolveInventory?.(agentPackageInventory)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(
|
||||
await screen.findByText('当前 1 / 2 个平台与架构目标可用。')
|
||||
).toBeInTheDocument()
|
||||
|
||||
getAgentPackageInventory.mockRejectedValueOnce(
|
||||
new Error('inventory unavailable')
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '刷新包清单' })
|
||||
)
|
||||
await screen.findByText('inventory unavailable')
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '刷新包清单' })
|
||||
)
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.queryByText('inventory unavailable')
|
||||
).not.toBeInTheDocument()
|
||||
)
|
||||
expect(getAgentPackageInventory).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('keeps local Agent packages usable when the online catalog is unavailable', async () => {
|
||||
getAgentPackageInventory.mockResolvedValueOnce({
|
||||
...agentPackageInventory,
|
||||
catalog: {
|
||||
state: 'unavailable',
|
||||
checkedAt: '2026-08-24T00:00:00.000Z',
|
||||
error: 'network unavailable'
|
||||
},
|
||||
entries: agentPackageInventory.entries.map((entry) => ({
|
||||
...entry,
|
||||
latestVersion: null,
|
||||
updateAvailable: false
|
||||
}))
|
||||
})
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={vi.fn()}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '平台功能' }))
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '远程项目' })
|
||||
)
|
||||
|
||||
expect(
|
||||
await screen.findByText(/network unavailable/u)
|
||||
).toBeInTheDocument()
|
||||
const x64Card = screen
|
||||
.getByText('Linux x64')
|
||||
.closest('[role="listitem"]')
|
||||
expect(
|
||||
within(x64Card as HTMLElement).getByRole('button', {
|
||||
name: '检查并更新'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
within(x64Card as HTMLElement).getByRole('button', {
|
||||
name: '导出离线包'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the SSH Hosts category and its local capability boundary only while Remote Projects is enabled', () => {
|
||||
const commonProps = {
|
||||
...heartbeatSettingsProps,
|
||||
open: true,
|
||||
onClearLocalData: vi.fn(async () => {}),
|
||||
onClose: vi.fn(),
|
||||
onSaved: vi.fn()
|
||||
}
|
||||
const { rerender } = render(
|
||||
<SettingsPanel
|
||||
{...commonProps}
|
||||
remoteProjectsEnabled={false}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: '主机与远程执行' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
rerender(
|
||||
<SettingsPanel
|
||||
{...commonProps}
|
||||
remoteProjectsEnabled
|
||||
/>
|
||||
)
|
||||
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '主机与远程执行' })
|
||||
).toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '主机与远程执行' })
|
||||
)
|
||||
expect(
|
||||
screen.getByText(
|
||||
'管理 SSH 主机、固定主机密钥并验证远端执行环境;本机配置的 Skills 和 MCP 不会应用于远程主机'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('falls back to Platform features when Remote Projects is disabled from SSH Hosts', async () => {
|
||||
const commonProps = {
|
||||
...heartbeatSettingsProps,
|
||||
initialCategory: 'ssh-hosts' as const,
|
||||
open: true,
|
||||
onClearLocalData: vi.fn(async () => {}),
|
||||
onClose: vi.fn(),
|
||||
onSaved: vi.fn()
|
||||
}
|
||||
const { rerender } = render(
|
||||
<SettingsPanel
|
||||
{...commonProps}
|
||||
remoteProjectsEnabled
|
||||
/>
|
||||
)
|
||||
await screen.findByText('尚未配置 SSH 主机')
|
||||
|
||||
rerender(
|
||||
<SettingsPanel
|
||||
{...commonProps}
|
||||
remoteProjectsEnabled={false}
|
||||
/>
|
||||
)
|
||||
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '平台功能' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
)
|
||||
expect(
|
||||
screen.queryByRole('tab', { name: '主机与远程执行' })
|
||||
).not.toBeInTheDocument()
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '远程项目' })
|
||||
)
|
||||
expect(
|
||||
await screen.findByRole('switch', {
|
||||
name: '远程项目'
|
||||
})
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('records physical Control separately from Command on macOS', async () => {
|
||||
shortcutSettingsSnapshot = {
|
||||
...shortcutSettingsSnapshot,
|
||||
@@ -2782,59 +2508,6 @@ describe('SettingsPanel runtime files', () => {
|
||||
expect(onClose).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('protects unsaved SSH host drafts across settings navigation', async () => {
|
||||
const onClose = vi.fn()
|
||||
render(
|
||||
<SettingsPanel
|
||||
{...heartbeatSettingsProps}
|
||||
initialCategory="ssh-hosts"
|
||||
open
|
||||
onClearLocalData={vi.fn(async () => {})}
|
||||
onClose={onClose}
|
||||
onSaved={vi.fn()}
|
||||
/>
|
||||
)
|
||||
|
||||
await screen.findByText('尚未配置 SSH 主机')
|
||||
fireEvent.click(
|
||||
screen.getAllByRole('button', { name: '添加主机' })[0]!
|
||||
)
|
||||
fireEvent.change(screen.getByLabelText('主机名称'), {
|
||||
target: { value: '尚未保存的主机' }
|
||||
})
|
||||
|
||||
const appearanceTab = screen.getByRole('tab', { name: '外观' })
|
||||
fireEvent.click(appearanceTab)
|
||||
expect(
|
||||
screen.getByRole('tab', { name: '主机与远程执行' })
|
||||
).toHaveAttribute('aria-selected', 'true')
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'当前设置有未保存更改'
|
||||
)
|
||||
expect(screen.getByLabelText('主机名称')).toHaveValue(
|
||||
'尚未保存的主机'
|
||||
)
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '继续编辑' })
|
||||
)
|
||||
fireEvent.click(appearanceTab)
|
||||
fireEvent.click(
|
||||
screen.getByRole('button', { name: '放弃更改并切换' })
|
||||
)
|
||||
expect(appearanceTab).toHaveAttribute('aria-selected', 'true')
|
||||
await waitFor(() => expect(appearanceTab).toHaveFocus())
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole('tab', { name: '主机与远程执行' })
|
||||
)
|
||||
await screen.findByText('尚未配置 SSH 主机')
|
||||
expect(screen.queryByLabelText('主机名称')).not.toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: '关闭设置' }))
|
||||
expect(onClose).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps shortcut input after a registration conflict', async () => {
|
||||
updateShortcutSettings.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
@@ -2983,15 +2656,15 @@ describe('SettingsPanel runtime files', () => {
|
||||
runtimeTab.focus()
|
||||
fireEvent.keyDown(runtimeTab, { key: 'ArrowRight' })
|
||||
|
||||
const sshHostsTab = screen.getByRole('tab', {
|
||||
name: '主机与远程执行'
|
||||
const documentParsingTab = screen.getByRole('tab', {
|
||||
name: '文档解析'
|
||||
})
|
||||
expect(sshHostsTab).toHaveFocus()
|
||||
expect(sshHostsTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(sshHostsTab).toHaveAttribute('tabindex', '0')
|
||||
expect(documentParsingTab).toHaveFocus()
|
||||
expect(documentParsingTab).toHaveAttribute('aria-selected', 'true')
|
||||
expect(documentParsingTab).toHaveAttribute('tabindex', '0')
|
||||
expect(runtimeTab).toHaveAttribute('tabindex', '-1')
|
||||
|
||||
fireEvent.keyDown(sshHostsTab, { key: 'End' })
|
||||
fireEvent.keyDown(documentParsingTab, { key: 'End' })
|
||||
const aboutTab = screen.getByRole('tab', { name: '关于与更新' })
|
||||
expect(aboutTab).toHaveFocus()
|
||||
expect(aboutTab).toHaveAttribute('aria-selected', 'true')
|
||||
@@ -4421,9 +4094,6 @@ describe('SettingsPanel runtime files', () => {
|
||||
.getByRole('heading', { level: 2, name: '平台功能' })
|
||||
.closest('.settings-category-header')
|
||||
expect(platformHeader).toHaveClass('settings-category-header--sticky')
|
||||
expect(
|
||||
platformHeader?.querySelector('.platform-features-tabs')
|
||||
).toBeInTheDocument()
|
||||
|
||||
fireEvent.click(screen.getByRole('tab', { name: '能力与工具' }))
|
||||
const capabilitiesHeader = screen
|
||||
|
||||
@@ -61,7 +61,6 @@ import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
|
||||
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
|
||||
import { DshMarketplaceSection } from './DshMarketplaceSection'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
import { SshHostsSettingsSection } from './SshHostsSettingsSection'
|
||||
import {
|
||||
RuntimeCustomizationSection,
|
||||
type RuntimeCustomizationSectionHandle
|
||||
@@ -227,7 +226,6 @@ type SettingsPanelProps = {
|
||||
initialCategory?: SettingsCategoryId
|
||||
initialChannel?: ProjectChannel
|
||||
onClose: () => void
|
||||
onProjectsDeleted?: (projectIds: string[]) => void
|
||||
onSaved: (settings: RuntimeSettings) => void
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
onUpdateProject: (
|
||||
@@ -253,11 +251,9 @@ type SettingsPanelProps = {
|
||||
preferences: BrandingPreferences
|
||||
) => boolean
|
||||
magicNotesEnabled?: boolean
|
||||
remoteProjectsEnabled?: boolean
|
||||
onConversationHtmlRenderingEnabledChange?: (
|
||||
enabled: boolean
|
||||
) => void
|
||||
onRemoteProjectsEnabledChange?: (enabled: boolean) => void
|
||||
onShortcutSettingsChanged?: (
|
||||
snapshot: GlobalShortcutSettingsSnapshot
|
||||
) => void
|
||||
@@ -647,7 +643,6 @@ function SettingsPanelView({
|
||||
initialCategory,
|
||||
initialChannel,
|
||||
onClose,
|
||||
onProjectsDeleted = () => {},
|
||||
onSaved,
|
||||
onNotify = () => {},
|
||||
onUpdateProject,
|
||||
@@ -668,9 +663,7 @@ function SettingsPanelView({
|
||||
brandingFallbackLogo,
|
||||
onBrandingPreferencesChange = () => false,
|
||||
magicNotesEnabled = false,
|
||||
remoteProjectsEnabled = false,
|
||||
onConversationHtmlRenderingEnabledChange = () => {},
|
||||
onRemoteProjectsEnabledChange = () => {},
|
||||
onShortcutSettingsChanged = () => {},
|
||||
onLeaveRequestReady = () => {}
|
||||
}: SettingsPanelProps): React.JSX.Element | null {
|
||||
@@ -797,15 +790,8 @@ function SettingsPanelView({
|
||||
const [detection, setDetection] = useState<AgentRuntimeDetection>()
|
||||
const [detecting, setDetecting] = useState(false)
|
||||
const [selectedTab, setActiveTab] =
|
||||
useState<SettingsCategoryId>(
|
||||
initialCategory === 'ssh-hosts' && !remoteProjectsEnabled
|
||||
? 'platform-features'
|
||||
: initialCategory ?? 'runtime'
|
||||
)
|
||||
const activeTab =
|
||||
selectedTab === 'ssh-hosts' && !remoteProjectsEnabled
|
||||
? 'platform-features'
|
||||
: selectedTab
|
||||
useState<SettingsCategoryId>(initialCategory ?? 'runtime')
|
||||
const activeTab = selectedTab
|
||||
const [modelType, setModelType] = useState<ModelType>('llm')
|
||||
const [modelDownloadSourceOpen, setModelDownloadSourceOpen] = useState(false)
|
||||
const [modelDownloadSource, setModelDownloadSource] = useState<ModelDownloadSource>()
|
||||
@@ -829,7 +815,6 @@ function SettingsPanelView({
|
||||
useState(false)
|
||||
const [documentParsingDirty, setDocumentParsingDirty] =
|
||||
useState(false)
|
||||
const [sshHostsDirty, setSshHostsDirty] = useState(false)
|
||||
const [pendingLeave, setPendingLeave] = useState<
|
||||
| { kind: 'close' }
|
||||
| { kind: 'navigate'; category: SettingsCategoryId; openModelDownloadSource?: boolean }
|
||||
@@ -915,7 +900,6 @@ function SettingsPanelView({
|
||||
const categoryRendersOwnHeader =
|
||||
activeTab === 'platform-features' ||
|
||||
activeTab === 'document-parsing' ||
|
||||
activeTab === 'ssh-hosts' ||
|
||||
activeTab === 'channels' ||
|
||||
activeTab === 'capabilities' ||
|
||||
activeTab === 'about'
|
||||
@@ -1018,22 +1002,19 @@ function SettingsPanelView({
|
||||
runtimeCustomizationDirty ||
|
||||
brandingDirty ||
|
||||
platformFeaturesDirty ||
|
||||
documentParsingDirty ||
|
||||
sshHostsDirty
|
||||
documentParsingDirty
|
||||
const navigationWouldLoseDraft =
|
||||
runtimeCustomizationDirty ||
|
||||
brandingDirty ||
|
||||
platformFeaturesDirty ||
|
||||
documentParsingDirty ||
|
||||
sshHostsDirty
|
||||
documentParsingDirty
|
||||
const unsavedCloseMessage =
|
||||
runtimeCustomizationDirty &&
|
||||
!runtimeDraftDirty &&
|
||||
!speechModelSelectionDirty &&
|
||||
!brandingDirty &&
|
||||
!platformFeaturesDirty &&
|
||||
!documentParsingDirty &&
|
||||
!sshHostsDirty
|
||||
!documentParsingDirty
|
||||
? t('runtime.customization.unsavedClose')
|
||||
: t('unsaved.close')
|
||||
|
||||
@@ -1077,9 +1058,7 @@ function SettingsPanelView({
|
||||
if (requestTabChange('model', undefined, true)) setModelDownloadSourceOpen(true)
|
||||
}
|
||||
|
||||
const visibleSettingsCategories = getSettingsCategoryList(
|
||||
remoteProjectsEnabled
|
||||
)
|
||||
const visibleSettingsCategories = getSettingsCategoryList()
|
||||
const settingsTabs = visibleSettingsCategories.map(({ id }) => id)
|
||||
|
||||
const handleTabKeyDown = (
|
||||
@@ -1131,7 +1110,6 @@ function SettingsPanelView({
|
||||
setSpeechModelSelectionDirty(false)
|
||||
setPlatformFeaturesDirty(false)
|
||||
setDocumentParsingDirty(false)
|
||||
setSshHostsDirty(false)
|
||||
setPendingLeave(undefined)
|
||||
setAgentRuntimeType('opencode')
|
||||
hydrateSettings(value)
|
||||
@@ -1300,7 +1278,6 @@ function SettingsPanelView({
|
||||
setBrandingDirty(false)
|
||||
setPlatformFeaturesDirty(false)
|
||||
setDocumentParsingDirty(false)
|
||||
setSshHostsDirty(false)
|
||||
setPendingLeave(undefined)
|
||||
setError(undefined)
|
||||
}
|
||||
@@ -1326,7 +1303,6 @@ function SettingsPanelView({
|
||||
setBrandingDirty(false)
|
||||
setPlatformFeaturesDirty(false)
|
||||
setDocumentParsingDirty(false)
|
||||
setSshHostsDirty(false)
|
||||
const leave = pendingLeave
|
||||
setPendingLeave(undefined)
|
||||
setError(undefined)
|
||||
@@ -2464,9 +2440,6 @@ function SettingsPanelView({
|
||||
onConversationHtmlRenderingEnabledChange={
|
||||
onConversationHtmlRenderingEnabledChange
|
||||
}
|
||||
onRemoteProjectsEnabledChange={
|
||||
onRemoteProjectsEnabledChange
|
||||
}
|
||||
onWorkspacePathChange={setWorkspacePath}
|
||||
workspacePath={workspacePath}
|
||||
onNotify={onNotify}
|
||||
@@ -4159,14 +4132,6 @@ function SettingsPanelView({
|
||||
/>
|
||||
)}
|
||||
|
||||
{remoteProjectsEnabled && activeTab === 'ssh-hosts' && (
|
||||
<SshHostsSettingsSection
|
||||
onDirtyChange={setSshHostsDirty}
|
||||
onNotify={onNotify}
|
||||
onProjectsDeleted={onProjectsDeleted}
|
||||
/>
|
||||
)}
|
||||
|
||||
{activeTab === 'platform-features' && (
|
||||
<>
|
||||
<div className="settings-section settings-section--danger">
|
||||
|
||||
@@ -204,10 +204,18 @@ async function inspectAndConfirmFirstKey(): Promise<void> {
|
||||
fireEvent.click(continueButton)
|
||||
}
|
||||
|
||||
function expandHost(host: HTMLElement, name: string): void {
|
||||
const toggle = within(host).queryByRole('button', {
|
||||
name: `展开 ${name} 的详情`
|
||||
})
|
||||
if (toggle) fireEvent.click(toggle)
|
||||
}
|
||||
|
||||
async function refreshHostEnvironment(
|
||||
name = 'Build host'
|
||||
): Promise<HTMLElement> {
|
||||
const host = await screen.findByRole('region', { name })
|
||||
expandHost(host, name)
|
||||
fireEvent.click(
|
||||
within(host).getByRole('button', {
|
||||
name: `刷新 ${name} 的远程运行环境版本`
|
||||
@@ -695,7 +703,7 @@ describe('SshHostsSettingsSection', () => {
|
||||
})
|
||||
|
||||
fireEvent.click(
|
||||
within(host).getByRole('button', { name: '删除' })
|
||||
within(host).getByRole('button', { name: '删除 Build host' })
|
||||
)
|
||||
const confirmation = screen.getByRole('alertdialog')
|
||||
expect(
|
||||
@@ -737,7 +745,8 @@ describe('SshHostsSettingsSection', () => {
|
||||
name: '验证 Build host'
|
||||
})
|
||||
).toHaveTextContent('验证并保存')
|
||||
expect(screen.getByText('需要重新验证')).toBeInTheDocument()
|
||||
// Shown as a summary badge and in the expanded details.
|
||||
expect(screen.getAllByText('需要重新验证')).toHaveLength(2)
|
||||
expect(getRemoteEnvironment).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
@@ -773,6 +782,10 @@ describe('SshHostsSettingsSection', () => {
|
||||
expect(within(host).getByText('0.10.4')).toBeInTheDocument()
|
||||
expect(within(host).getByText('0.11.1')).toBeInTheDocument()
|
||||
expect(within(host).getAllByText('1.18.29')).toHaveLength(2)
|
||||
const versions = within(host).getByRole('table')
|
||||
expect(within(versions).getAllByRole('columnheader').map((cell) => cell.textContent)).toEqual(['组件', 'Host 已安装', 'GoodBuddy 所需', '状态'])
|
||||
expect(within(versions).getByRole('rowheader', { name: 'GoodBuddy Agent' })).toBeInTheDocument()
|
||||
expect(within(versions).getByRole('rowheader', { name: 'OpenCode Runtime' })).toBeInTheDocument()
|
||||
expect(within(host).getByText('安装方式')).toBeInTheDocument()
|
||||
expect(
|
||||
within(host)
|
||||
@@ -822,13 +835,28 @@ describe('SshHostsSettingsSection', () => {
|
||||
})
|
||||
render(<SshHostsSettingsSection />)
|
||||
|
||||
await screen.findByRole('region', { name: 'Deploy host' })
|
||||
const deployHost = await screen.findByRole('region', { name: 'Deploy host' })
|
||||
expect(getRemoteEnvironment).not.toHaveBeenCalled()
|
||||
// Several hosts start as one-line summaries.
|
||||
expect(within(deployHost).getByText('deploy.example.com', { exact: false })).toBeInTheDocument()
|
||||
expect(within(deployHost).getByText('环境未检查')).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getAllByText(
|
||||
screen.queryByText(
|
||||
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。'
|
||||
)
|
||||
).toHaveLength(2)
|
||||
).not.toBeInTheDocument()
|
||||
const toggle = within(deployHost).getByRole('button', { name: '展开 Deploy host 的详情' })
|
||||
expect(toggle).toHaveAttribute('aria-expanded', 'false')
|
||||
fireEvent.click(toggle)
|
||||
expect(
|
||||
within(deployHost).getByRole('button', { name: '收起 Deploy host 的详情' })
|
||||
).toHaveAttribute('aria-expanded', 'true')
|
||||
expect(
|
||||
within(deployHost).getByText(
|
||||
'尚未检查版本。本页面不会自动连接主机;点击“刷新版本”可检查版本。'
|
||||
)
|
||||
).toBeInTheDocument()
|
||||
expect(getRemoteEnvironment).not.toHaveBeenCalled()
|
||||
|
||||
await refreshHostEnvironment()
|
||||
expect(getRemoteEnvironment).toHaveBeenCalledTimes(1)
|
||||
@@ -1281,7 +1309,7 @@ describe('SshHostsSettingsSection', () => {
|
||||
})
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
within(buildHost).getByRole('button', { name: '删除' })
|
||||
within(buildHost).getByRole('button', { name: '删除 Build host' })
|
||||
).toBeDisabled()
|
||||
expect(
|
||||
within(deployHost).getByRole('button', {
|
||||
|
||||
@@ -2,11 +2,12 @@ import {
|
||||
Bot,
|
||||
Boxes,
|
||||
CheckCircle2,
|
||||
ChevronRight,
|
||||
Download,
|
||||
LoaderCircle,
|
||||
Pencil,
|
||||
Plus,
|
||||
RefreshCw,
|
||||
Search,
|
||||
Server,
|
||||
ShieldCheck,
|
||||
X
|
||||
@@ -28,9 +29,9 @@ import type {
|
||||
SshHostValidationResult
|
||||
} from '../../shared/ssh-host-contracts'
|
||||
import type { AppNotificationInput } from './notifications'
|
||||
import { SettingsCategoryHeader } from './SettingsPrimitives'
|
||||
import { displayErrorMessage } from './error-message'
|
||||
import { SshHostDialog } from './SshHostDialog'
|
||||
import './remote-hosts.css'
|
||||
import {
|
||||
getCachedSshHostRemoteEnvironments,
|
||||
removeCachedSshHostRemoteEnvironment,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
} from './WorkspacePrimitives'
|
||||
|
||||
type SshHostsSettingsSectionProps = {
|
||||
onBusyChange?: (busy: boolean) => void
|
||||
onDirtyChange?: (dirty: boolean) => void
|
||||
onHostUpdated?: (hostId: string) => void
|
||||
onNotify?: (notification: AppNotificationInput) => void
|
||||
@@ -95,6 +97,7 @@ function upsertHost(hosts: SshHost[], host: SshHost): SshHost[] {
|
||||
}
|
||||
|
||||
export function SshHostsSettingsSection({
|
||||
onBusyChange,
|
||||
onDirtyChange,
|
||||
onHostUpdated,
|
||||
onNotify,
|
||||
@@ -110,6 +113,14 @@ export function SshHostsSettingsSection({
|
||||
const [loadError, setLoadError] = useState<string>()
|
||||
const [error, setError] = useState<string>()
|
||||
const [editingId, setEditingId] = useState<string | 'new'>()
|
||||
const [query, setQuery] = useState('')
|
||||
// Hosts start collapsed so long lists stay scannable; a single host starts open.
|
||||
const [expandedOverrides, setExpandedOverrides] = useState<
|
||||
ReadonlyMap<string, boolean>
|
||||
>(() => new Map())
|
||||
const setHostExpanded = (hostId: string, expanded: boolean): void => {
|
||||
setExpandedOverrides((current) => new Map(current).set(hostId, expanded))
|
||||
}
|
||||
const [busyHostId, setBusyHostId] = useState<string>()
|
||||
const [confirmingDeleteId, setConfirmingDeleteId] =
|
||||
useState<string>()
|
||||
@@ -510,6 +521,8 @@ export function SshHostsSettingsSection({
|
||||
const edited = hosts.some((host) => host.id === result.host.id)
|
||||
removeCachedSshHostRemoteEnvironment(result.host.id)
|
||||
setHosts((current) => upsertHost(current, result.host))
|
||||
// Show the validation result and next step for the host just saved.
|
||||
setHostExpanded(result.host.id, true)
|
||||
setTestResults((current) => ({
|
||||
...current,
|
||||
[result.host.id]: result.connection
|
||||
@@ -604,27 +617,53 @@ export function SshHostsSettingsSection({
|
||||
const hasActiveRemoteEnvironmentUpdate =
|
||||
remoteEnvironmentUpdate !== undefined
|
||||
|
||||
useEffect(() => {
|
||||
onBusyChange?.(hasActiveRemoteEnvironmentUpdate)
|
||||
}, [hasActiveRemoteEnvironmentUpdate, onBusyChange])
|
||||
useEffect(() => () => onBusyChange?.(false), [onBusyChange])
|
||||
|
||||
const normalizedQuery = query.trim().toLocaleLowerCase()
|
||||
const visibleHosts = normalizedQuery
|
||||
? hosts.filter((host) =>
|
||||
`${host.name} ${host.username}@${host.hostname}:${host.port}`
|
||||
.toLocaleLowerCase()
|
||||
.includes(normalizedQuery)
|
||||
)
|
||||
: hosts
|
||||
|
||||
return (
|
||||
<div className="ssh-hosts-settings">
|
||||
<SettingsCategoryHeader
|
||||
actions={
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={loading || Boolean(editingId) || !api}
|
||||
onClick={() => {
|
||||
setEditingId('new')
|
||||
setError(undefined)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" size={14} />
|
||||
{t('sshHosts.actions.add')}
|
||||
</button>
|
||||
}
|
||||
category="ssh-hosts"
|
||||
error={error}
|
||||
headingId="ssh-hosts-heading"
|
||||
/>
|
||||
<div className="ssh-hosts-toolbar">
|
||||
{hosts.length > 0 && (
|
||||
<label className="ssh-hosts-toolbar__search">
|
||||
<Search aria-hidden="true" size={14} />
|
||||
<input
|
||||
aria-label={t('sshHosts.search.label')}
|
||||
onChange={(event) => setQuery(event.target.value)}
|
||||
placeholder={t('sshHosts.search.placeholder')}
|
||||
type="search"
|
||||
value={query}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<button
|
||||
className="primary-button"
|
||||
disabled={loading || Boolean(editingId) || !api}
|
||||
onClick={() => {
|
||||
setEditingId('new')
|
||||
setError(undefined)
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Plus aria-hidden="true" size={14} />
|
||||
{t('sshHosts.actions.add')}
|
||||
</button>
|
||||
</div>
|
||||
{error && (
|
||||
<p className="settings-warning" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="settings-notice">
|
||||
<ShieldCheck aria-hidden="true" size={16} />
|
||||
@@ -676,12 +715,26 @@ export function SshHostsSettingsSection({
|
||||
icon={<Server size={22} />}
|
||||
title={t('sshHosts.empty.title')}
|
||||
/>
|
||||
) : visibleHosts.length === 0 ? (
|
||||
<EmptyState
|
||||
action={
|
||||
<button
|
||||
className="secondary-button"
|
||||
onClick={() => setQuery('')}
|
||||
type="button"
|
||||
>
|
||||
{t('sshHosts.search.clear')}
|
||||
</button>
|
||||
}
|
||||
description={t('sshHosts.search.noResults')}
|
||||
icon={<Search size={22} />}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
aria-label={t('sshHosts.listLabel')}
|
||||
className="ssh-hosts-list"
|
||||
>
|
||||
{hosts.map((host) => {
|
||||
{visibleHosts.map((host) => {
|
||||
const relatedProjects =
|
||||
projectReferences?.[host.id] ?? []
|
||||
const result = testResults[host.id]
|
||||
@@ -722,20 +775,98 @@ export function SshHostsSettingsSection({
|
||||
'function' &&
|
||||
typeof api.onRemoteEnvironmentUpdateProgress ===
|
||||
'function'
|
||||
const environmentSummary = remoteEnvironment?.value
|
||||
? environmentComponents.some(
|
||||
({ state }) => state === 'not-installed'
|
||||
)
|
||||
? 'not-installed'
|
||||
: environmentComponents.some(
|
||||
({ state }) => state === 'update-available'
|
||||
)
|
||||
? 'update-available'
|
||||
: 'current'
|
||||
: 'unknown'
|
||||
// Progress, failures, and removal confirmation must stay visible.
|
||||
const forcedOpen =
|
||||
Boolean(hostEnvironmentUpdate) ||
|
||||
Boolean(remoteEnvironmentUpdateError) ||
|
||||
Boolean(remoteEnvironment?.error) ||
|
||||
confirmingDeleteId === host.id
|
||||
const expanded =
|
||||
forcedOpen ||
|
||||
(expandedOverrides.get(host.id) ?? hosts.length === 1)
|
||||
const detailsId = `ssh-host-details-${host.id}`
|
||||
return (
|
||||
<section
|
||||
aria-label={host.name}
|
||||
className="settings-section ssh-host-card"
|
||||
className={`settings-section ssh-host-card${expanded ? ' ssh-host-card--expanded' : ''}`}
|
||||
key={host.id}
|
||||
>
|
||||
<div className="settings-section__title settings-section__title--actions">
|
||||
<Server aria-hidden="true" size={17} />
|
||||
<div>
|
||||
<strong>{host.name}</strong>
|
||||
<small>
|
||||
{host.username}@{host.hostname}:{host.port}
|
||||
</small>
|
||||
</div>
|
||||
<div className="ssh-host-card__summary">
|
||||
<button
|
||||
aria-controls={detailsId}
|
||||
aria-expanded={expanded}
|
||||
aria-label={t(
|
||||
expanded
|
||||
? 'sshHosts.list.collapseNamed'
|
||||
: 'sshHosts.list.expandNamed',
|
||||
{ name: host.name }
|
||||
)}
|
||||
className="ssh-host-card__toggle"
|
||||
disabled={forcedOpen}
|
||||
onClick={() => setHostExpanded(host.id, !expanded)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronRight
|
||||
aria-hidden="true"
|
||||
className="ssh-host-card__chevron"
|
||||
size={15}
|
||||
/>
|
||||
<Server aria-hidden="true" size={16} />
|
||||
<span className="ssh-host-card__identity">
|
||||
<strong>{host.name}</strong>
|
||||
<small>
|
||||
{host.username}@{host.hostname}:{host.port}
|
||||
</small>
|
||||
</span>
|
||||
</button>
|
||||
<span className="ssh-host-card__badges">
|
||||
<span
|
||||
className={`ssh-host-badge ssh-host-badge--${host.lastValidatedAt ? 'ok' : 'warn'}`}
|
||||
>
|
||||
{t(
|
||||
host.lastValidatedAt
|
||||
? 'sshHosts.list.validated'
|
||||
: 'sshHosts.status.needsValidation'
|
||||
)}
|
||||
</span>
|
||||
{host.lastValidatedAt && (
|
||||
<span
|
||||
className={`ssh-host-badge ssh-host-badge--${
|
||||
hostEnvironmentUpdate
|
||||
? 'busy'
|
||||
: environmentSummary === 'current'
|
||||
? 'ok'
|
||||
: environmentSummary === 'unknown'
|
||||
? 'muted'
|
||||
: 'warn'
|
||||
}`}
|
||||
>
|
||||
{t(
|
||||
hostEnvironmentUpdate
|
||||
? 'sshHosts.list.environmentUpdating'
|
||||
: `sshHosts.list.environment.${environmentSummary}`
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
{relatedProjects.length > 0 && (
|
||||
<span className="ssh-host-badge ssh-host-badge--muted">
|
||||
{t('sshHosts.list.projects', {
|
||||
count: relatedProjects.length
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<button
|
||||
aria-label={t(
|
||||
host.lastValidatedAt
|
||||
@@ -755,13 +886,57 @@ export function SshHostsSettingsSection({
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Pencil aria-hidden="true" size={13} />
|
||||
{host.lastValidatedAt
|
||||
? t('sshHosts.actions.edit')
|
||||
: t('sshHosts.actions.validate')}
|
||||
</button>
|
||||
<DestructiveConfirmActions
|
||||
confirmLabel={t(
|
||||
'sshHosts.actions.confirmRemove'
|
||||
)}
|
||||
confirming={confirmingDeleteId === host.id}
|
||||
disabled={
|
||||
deleting || Boolean(hostEnvironmentUpdate)
|
||||
}
|
||||
message={
|
||||
confirmingDeleteId === host.id ? (
|
||||
<div className="ssh-host-remove-confirmation">
|
||||
<p>
|
||||
{t('sshHosts.removeMessage', {
|
||||
name: host.name
|
||||
})}
|
||||
</p>
|
||||
{relatedProjects.length > 0 && (
|
||||
<>
|
||||
<strong>
|
||||
{t('sshHosts.removeProjectsHeading')}
|
||||
</strong>
|
||||
<ul>
|
||||
{relatedProjects.map((project) => (
|
||||
<li key={project.id}>
|
||||
{project.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
onCancel={() =>
|
||||
setConfirmingDeleteId(undefined)
|
||||
}
|
||||
onConfirm={() => void remove(host)}
|
||||
onRequestConfirm={() =>
|
||||
setConfirmingDeleteId(host.id)
|
||||
}
|
||||
triggerAriaLabel={t('sshHosts.actions.removeNamed', { name: host.name })}
|
||||
triggerLabel={t('sshHosts.actions.remove')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{expanded && (
|
||||
<div className="ssh-host-card__details" id={detailsId}>
|
||||
<dl className="ssh-host-card__facts">
|
||||
<div>
|
||||
<dt>{t('sshHosts.fields.authentication')}</dt>
|
||||
@@ -1053,39 +1228,49 @@ export function SshHostsSettingsSection({
|
||||
)}
|
||||
|
||||
{remoteEnvironment?.value ? (
|
||||
<div className="ssh-host-environment__grid">
|
||||
<EnvironmentVersionCard
|
||||
icon={<Bot aria-hidden="true" size={17} />}
|
||||
installed={
|
||||
remoteEnvironment.value.agent.installed
|
||||
}
|
||||
label="GoodBuddy Agent"
|
||||
state={remoteEnvironment.value.agent.state}
|
||||
t={t}
|
||||
expected={
|
||||
remoteEnvironment.value.agent.expected
|
||||
}
|
||||
/>
|
||||
{remoteEnvironment.value.runtimes.map(
|
||||
(runtime) => (
|
||||
<EnvironmentVersionCard
|
||||
icon={
|
||||
<Server aria-hidden="true" size={17} />
|
||||
}
|
||||
installed={runtime.installed}
|
||||
key={runtime.runtimeId}
|
||||
label={
|
||||
runtime.runtimeId === 'opencode'
|
||||
? 'OpenCode Runtime'
|
||||
: runtime.runtimeId
|
||||
}
|
||||
state={runtime.state}
|
||||
t={t}
|
||||
expected={runtime.expected}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
<table className="ssh-host-environment__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col">{t('sshHosts.environment.component')}</th>
|
||||
<th scope="col">{t('sshHosts.environment.installed')}</th>
|
||||
<th scope="col">{t('sshHosts.environment.expected')}</th>
|
||||
<th scope="col">{t('sshHosts.environment.status')}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<EnvironmentVersionRow
|
||||
icon={<Bot aria-hidden="true" size={15} />}
|
||||
installed={
|
||||
remoteEnvironment.value.agent.installed
|
||||
}
|
||||
label="GoodBuddy Agent"
|
||||
state={remoteEnvironment.value.agent.state}
|
||||
t={t}
|
||||
expected={
|
||||
remoteEnvironment.value.agent.expected
|
||||
}
|
||||
/>
|
||||
{remoteEnvironment.value.runtimes.map(
|
||||
(runtime) => (
|
||||
<EnvironmentVersionRow
|
||||
icon={
|
||||
<Server aria-hidden="true" size={15} />
|
||||
}
|
||||
installed={runtime.installed}
|
||||
key={runtime.runtimeId}
|
||||
label={
|
||||
runtime.runtimeId === 'opencode'
|
||||
? 'OpenCode Runtime'
|
||||
: runtime.runtimeId
|
||||
}
|
||||
state={runtime.state}
|
||||
t={t}
|
||||
expected={runtime.expected}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
) : remoteEnvironment?.loading ? (
|
||||
<div
|
||||
className="ssh-host-environment__loading"
|
||||
@@ -1106,50 +1291,8 @@ export function SshHostsSettingsSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="ssh-host-card__actions">
|
||||
<DestructiveConfirmActions
|
||||
confirmLabel={t(
|
||||
'sshHosts.actions.confirmRemove'
|
||||
)}
|
||||
confirming={confirmingDeleteId === host.id}
|
||||
disabled={
|
||||
deleting || Boolean(hostEnvironmentUpdate)
|
||||
}
|
||||
message={
|
||||
confirmingDeleteId === host.id ? (
|
||||
<div className="ssh-host-remove-confirmation">
|
||||
<p>
|
||||
{t('sshHosts.removeMessage', {
|
||||
name: host.name
|
||||
})}
|
||||
</p>
|
||||
{relatedProjects.length > 0 && (
|
||||
<>
|
||||
<strong>
|
||||
{t('sshHosts.removeProjectsHeading')}
|
||||
</strong>
|
||||
<ul>
|
||||
{relatedProjects.map((project) => (
|
||||
<li key={project.id}>
|
||||
{project.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
) : null
|
||||
}
|
||||
onCancel={() =>
|
||||
setConfirmingDeleteId(undefined)
|
||||
}
|
||||
onConfirm={() => void remove(host)}
|
||||
onRequestConfirm={() =>
|
||||
setConfirmingDeleteId(host.id)
|
||||
}
|
||||
triggerLabel={t('sshHosts.actions.remove')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
@@ -1198,7 +1341,7 @@ function formatPackageSize(bytes: number): string {
|
||||
return `${mebibytes >= 10 ? Math.round(mebibytes) : mebibytes.toFixed(1)} MiB`
|
||||
}
|
||||
|
||||
function EnvironmentVersionCard({
|
||||
function EnvironmentVersionRow({
|
||||
expected,
|
||||
icon,
|
||||
installed,
|
||||
@@ -1216,48 +1359,44 @@ function EnvironmentVersionCard({
|
||||
t: ReturnType<typeof useTranslation<'settingsSections'>>['t']
|
||||
}): React.JSX.Element {
|
||||
return (
|
||||
<article className="ssh-host-environment__runtime">
|
||||
<div className="ssh-host-environment__runtime-header">
|
||||
<span className="ssh-host-environment__runtime-icon">
|
||||
<tr>
|
||||
<th scope="row">
|
||||
<span className="ssh-host-environment__component">
|
||||
{icon}
|
||||
{label}
|
||||
</span>
|
||||
<strong>{label}</strong>
|
||||
</th>
|
||||
<td>
|
||||
{installed ? (
|
||||
<span
|
||||
title={t('sshHosts.environment.versionDetail', {
|
||||
architecture: installed.architecture
|
||||
})}
|
||||
>
|
||||
{installed.version}
|
||||
</span>
|
||||
) : (
|
||||
<span className="ssh-host-environment__missing">
|
||||
{t('sshHosts.environment.notInstalled')}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
title={t('sshHosts.environment.versionDetail', {
|
||||
architecture: expected.architecture
|
||||
})}
|
||||
>
|
||||
{expected.version}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<span
|
||||
className={`ssh-host-environment__badge ssh-host-environment__badge--${state}`}
|
||||
>
|
||||
{t(`sshHosts.environment.states.${state}`)}
|
||||
</span>
|
||||
</div>
|
||||
<dl>
|
||||
<div>
|
||||
<dt>{t('sshHosts.environment.installed')}</dt>
|
||||
<dd>
|
||||
{installed ? (
|
||||
<>
|
||||
<strong>{installed.version}</strong>
|
||||
<small>
|
||||
{t('sshHosts.environment.versionDetail', {
|
||||
architecture: installed.architecture
|
||||
})}
|
||||
</small>
|
||||
</>
|
||||
) : (
|
||||
<span>{t('sshHosts.environment.notInstalled')}</span>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>{t('sshHosts.environment.expected')}</dt>
|
||||
<dd>
|
||||
<strong>{expected.version}</strong>
|
||||
<small>
|
||||
{t('sshHosts.environment.versionDetail', {
|
||||
architecture: expected.architecture
|
||||
})}
|
||||
</small>
|
||||
</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -132,6 +132,16 @@
|
||||
.application-settings .toggle-row span {
|
||||
font-weight: 500;
|
||||
}
|
||||
/* A switch with inline help keeps the help next to its text and the switch
|
||||
on the right edge, aligned with the other switches. */
|
||||
.application-settings .inline-help-label {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
}
|
||||
.application-settings .inline-help-label > .toggle-row {
|
||||
display: contents;
|
||||
}
|
||||
.application-settings .settings-notice {
|
||||
margin: 0 0 var(--space-2);
|
||||
font-size: var(--font-body);
|
||||
|
||||
@@ -35,12 +35,27 @@ export const app = {
|
||||
enabled: 'Enabled', disabled: 'Disabled', open: 'Open', moveUp: 'Move {{name}} up', moveDown: 'Move {{name}} down',
|
||||
orderUpdated: 'Application order updated', loadFailed: 'Could not load application settings', saveFailed: 'Could not save application settings',
|
||||
confirmFailed: 'Could not confirm the save result. Reload settings before editing again.',
|
||||
remoteHosts: 'Hosts',
|
||||
remoteHostsEnable: 'Enable remote capability',
|
||||
remoteHostsHelp: 'Remote capability is off by default. Enable it to add SSH hosts, install execution environments, and create remote projects. Turning it off hides remote projects and stops remote execution; hosts, credentials, remote projects, and Agent packages are kept. Agent packages remain manageable while it is off.',
|
||||
descriptions: {
|
||||
'device-sharing': 'Test feature, off by default. Register devices, publish and revoke capability or knowledge metadata.',
|
||||
'remote-hosts': 'Connect SSH hosts, install remote execution environments, and manage Agent packages.',
|
||||
'magic-notes': 'Write notes, organize to-dos, and receive AI comments.', knowledge: 'Manage knowledge sources and retrieve reference material for conversations.',
|
||||
heartbeat: 'Review work evolution, explore the story graph, and configure Smart heartbeat.', 'local-inference': 'Inspect local inference tasks and execution services.'
|
||||
}
|
||||
},
|
||||
remoteHosts: {
|
||||
title: 'Hosts',
|
||||
description: 'Connect SSH hosts, install remote execution environments, and manage Agent packages. Local Skills and MCP servers are not applied to remote hosts.',
|
||||
tabsLabel: 'Hosts',
|
||||
tabs: { hosts: 'Hosts', packages: 'Agent packages' },
|
||||
disabled: {
|
||||
title: 'Remote capability is off',
|
||||
description: 'Enable it to add SSH hosts, install execution environments, and create remote projects. Agent packages can be downloaded or imported ahead of time while it is off.',
|
||||
enable: 'Enable remote capability'
|
||||
}
|
||||
},
|
||||
notifications: {
|
||||
success: 'Success',
|
||||
error: 'Error',
|
||||
|
||||
@@ -12,6 +12,25 @@ export const settingsSections = {
|
||||
loading: 'Loading SSH hosts…',
|
||||
loadFailedTitle: 'Could not load SSH hosts',
|
||||
listLabel: 'SSH host list',
|
||||
list: {
|
||||
expandNamed: 'Show details for {{name}}',
|
||||
collapseNamed: 'Hide details for {{name}}',
|
||||
validated: 'Validated',
|
||||
environmentUpdating: 'Installing',
|
||||
environment: {
|
||||
current: 'Environment ready',
|
||||
'update-available': 'Update available',
|
||||
'not-installed': 'Not installed',
|
||||
unknown: 'Not checked'
|
||||
},
|
||||
projects: '{{count}} projects'
|
||||
},
|
||||
search: {
|
||||
label: 'Search hosts',
|
||||
placeholder: 'Search by name or address',
|
||||
noResults: 'No matching hosts',
|
||||
clear: 'Clear search'
|
||||
},
|
||||
securityNotice:
|
||||
'When a host is added or edited, GoodBuddy inspects its host key before sending credentials and running a bounded system probe. The host and encrypted password are saved only after every step succeeds.',
|
||||
secureStorageUnavailable:
|
||||
@@ -137,6 +156,8 @@ export const settingsSections = {
|
||||
reinstallFailedSummary:
|
||||
'This reinstall did not complete. Checking the current versions again.'
|
||||
},
|
||||
component: 'Component',
|
||||
status: 'Status',
|
||||
installed: 'Installed on Host',
|
||||
expected: 'Required by GoodBuddy',
|
||||
notInstalled: 'Not installed',
|
||||
@@ -200,6 +221,7 @@ export const settingsSections = {
|
||||
validateNamed: 'Validate {{name}}',
|
||||
inspecting: 'Inspecting…',
|
||||
remove: 'Delete',
|
||||
removeNamed: 'Delete {{name}}',
|
||||
confirmRemove: 'Confirm delete',
|
||||
cancel: 'Cancel',
|
||||
closeDialog: 'Close SSH host validation',
|
||||
|
||||
@@ -147,17 +147,17 @@ export const workspace = {
|
||||
loadingHosts: 'Loading saved hosts…',
|
||||
noHosts: 'No saved SSH hosts',
|
||||
hostHelp:
|
||||
'Host credentials remain centrally managed in Settings and are not copied into the project.',
|
||||
'Host credentials remain centrally managed in Apps > Hosts and are not copied into the project.',
|
||||
readiness: {
|
||||
loading: 'Reading the Host validation record…',
|
||||
ready:
|
||||
'This Host is validated. Saving connects and checks the Agent, workspace, and Runtime.',
|
||||
unready:
|
||||
'This Host has not completed Host Key and connection validation. Validate it under Settings > SSH Hosts first.',
|
||||
'This Host has not completed Host Key and connection validation. Validate it under Apps > Hosts first.',
|
||||
error:
|
||||
'Could not read this Host’s local validation record. Check it under Settings > SSH Hosts.',
|
||||
'Could not read this Host’s local validation record. Check it under Apps > Hosts.',
|
||||
saveBlocked:
|
||||
'The selected Host has not been validated. Validate its connection under Settings > SSH Hosts first.',
|
||||
'The selected Host has not been validated. Validate its connection under Apps > Hosts first.',
|
||||
options: {
|
||||
loading: 'Reading',
|
||||
ready: 'Validated',
|
||||
|
||||
@@ -32,12 +32,27 @@ export const app = {
|
||||
enabled: '已启用', disabled: '已关闭', open: '打开', moveUp: '上移 {{name}}', moveDown: '下移 {{name}}',
|
||||
orderUpdated: '应用顺序已更新', loadFailed: '无法读取应用设置', saveFailed: '应用设置保存失败',
|
||||
confirmFailed: '无法确认保存结果,请重新读取后再修改。',
|
||||
remoteHosts: '主机管理',
|
||||
remoteHostsEnable: '启用远程能力',
|
||||
remoteHostsHelp: '远程能力默认关闭。启用后可以添加 SSH 主机、安装执行环境并创建远程项目。关闭后远程项目从项目列表中隐藏,也不能再在远端执行;主机、凭据、远程项目和 Agent 离线包都会保留。Agent 离线包在关闭时仍可管理。',
|
||||
descriptions: {
|
||||
'device-sharing': '测试功能,默认关闭。注册设备,发布和撤销能力或知识的元数据。',
|
||||
'remote-hosts': '接入 SSH 主机,安装远端执行环境,管理 Agent 离线包。',
|
||||
'magic-notes': '记录笔记、整理待办并获取 AI 评论。', knowledge: '管理知识来源,为会话检索资料。',
|
||||
heartbeat: '回顾工作演变、查看故事线图谱,并配置智能心跳。', 'local-inference': '查看本机推理任务与执行服务。'
|
||||
}
|
||||
},
|
||||
remoteHosts: {
|
||||
title: '主机管理',
|
||||
description: '接入 SSH 主机,安装远端执行环境,管理 Agent 离线包。本机配置的 Skills 和 MCP 不会应用到远程主机。',
|
||||
tabsLabel: '主机管理',
|
||||
tabs: { hosts: '主机', packages: 'Agent 离线包' },
|
||||
disabled: {
|
||||
title: '远程能力未启用',
|
||||
description: '启用后可以添加 SSH 主机、安装执行环境并创建远程项目。Agent 离线包在未启用时也可以提前下载或导入。',
|
||||
enable: '启用远程能力'
|
||||
}
|
||||
},
|
||||
notifications: {
|
||||
success: '成功',
|
||||
error: '错误',
|
||||
|
||||
@@ -7,6 +7,25 @@ export const settingsSections = {
|
||||
loading: '正在读取 SSH 主机…',
|
||||
loadFailedTitle: 'SSH 主机加载失败',
|
||||
listLabel: 'SSH 主机列表',
|
||||
list: {
|
||||
expandNamed: '展开 {{name}} 的详情',
|
||||
collapseNamed: '收起 {{name}} 的详情',
|
||||
validated: '已验证',
|
||||
environmentUpdating: '安装中',
|
||||
environment: {
|
||||
current: '环境就绪',
|
||||
'update-available': '环境待更新',
|
||||
'not-installed': '环境未安装',
|
||||
unknown: '环境未检查'
|
||||
},
|
||||
projects: '{{count}} 个项目'
|
||||
},
|
||||
search: {
|
||||
label: '搜索主机',
|
||||
placeholder: '搜索名称或地址',
|
||||
noResults: '没有匹配的主机',
|
||||
clear: '清除搜索'
|
||||
},
|
||||
securityNotice:
|
||||
'添加或编辑主机时,GoodBuddy 会先检查 Host Key,再发送认证凭据并执行有界系统探测。只有全部成功后才会保存主机和加密密码。',
|
||||
secureStorageUnavailable:
|
||||
@@ -129,6 +148,8 @@ export const settingsSections = {
|
||||
reinstallFailedSummary:
|
||||
'本次重新安装未完成;正在重新检查当前版本。'
|
||||
},
|
||||
component: '组件',
|
||||
status: '状态',
|
||||
installed: 'Host 已安装',
|
||||
expected: 'GoodBuddy 所需',
|
||||
notInstalled: '尚未安装',
|
||||
@@ -192,6 +213,7 @@ export const settingsSections = {
|
||||
validateNamed: '验证 {{name}}',
|
||||
inspecting: '正在检查…',
|
||||
remove: '删除',
|
||||
removeNamed: '删除 {{name}}',
|
||||
confirmRemove: '确认删除',
|
||||
cancel: '取消',
|
||||
closeDialog: '关闭 SSH 主机验证',
|
||||
|
||||
@@ -143,17 +143,17 @@ export const workspace = {
|
||||
},
|
||||
loadingHosts: '正在读取已保存主机…',
|
||||
noHosts: '没有已保存的 SSH 主机',
|
||||
hostHelp: '主机凭据由设置中心统一管理,不会复制到项目中。',
|
||||
hostHelp: '主机凭据在“应用 > 主机管理”中统一管理,不会复制到项目中。',
|
||||
readiness: {
|
||||
loading: '正在读取主机验证记录…',
|
||||
ready:
|
||||
'此主机已验证。保存项目时才会连接并检查 Agent、工作区和 Runtime。',
|
||||
unready:
|
||||
'此主机尚未完成 Host Key 和连接验证。请先前往“设置 > SSH 主机”完成验证。',
|
||||
'此主机尚未完成 Host Key 和连接验证。请先前往“应用 > 主机管理”完成验证。',
|
||||
error:
|
||||
'无法读取此主机的本地验证记录。请前往“设置 > SSH 主机”检查配置。',
|
||||
'无法读取此主机的本地验证记录。请前往“应用 > 主机管理”检查配置。',
|
||||
saveBlocked:
|
||||
'所选主机尚未完成验证。请先在“设置 > SSH 主机”验证连接。',
|
||||
'所选主机尚未完成验证。请先在“应用 > 主机管理”验证连接。',
|
||||
options: {
|
||||
loading: '读取中',
|
||||
ready: '已验证',
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -4,19 +4,13 @@ import { getSettingsCategoryList } from './settings-categories'
|
||||
describe('getSettingsCategoryList', () => {
|
||||
it('defines an icon for every settings category', () => {
|
||||
expect(
|
||||
getSettingsCategoryList(true).every(({ icon }) => icon != null)
|
||||
getSettingsCategoryList().every(({ icon }) => icon != null)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('hides SSH hosts while Remote Projects is disabled', () => {
|
||||
it('no longer lists SSH hosts, which moved to the Hosts application', () => {
|
||||
expect(
|
||||
getSettingsCategoryList(false).map(({ id }) => id)
|
||||
getSettingsCategoryList().map(({ id }) => id as string)
|
||||
).not.toContain('ssh-hosts')
|
||||
})
|
||||
|
||||
it('includes SSH hosts while Remote Projects is enabled', () => {
|
||||
expect(
|
||||
getSettingsCategoryList(true).map(({ id }) => id)
|
||||
).toContain('ssh-hosts')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import {
|
||||
Info,
|
||||
Palette,
|
||||
Radio,
|
||||
Server,
|
||||
|
||||
SlidersHorizontal,
|
||||
Terminal,
|
||||
Users,
|
||||
@@ -44,12 +44,7 @@ export const settingsCategoryList = [
|
||||
translationKey: 'runtime',
|
||||
icon: Terminal
|
||||
},
|
||||
{
|
||||
id: 'ssh-hosts',
|
||||
group: 'execution',
|
||||
translationKey: 'sshHosts',
|
||||
icon: Server
|
||||
},
|
||||
|
||||
{
|
||||
id: 'document-parsing',
|
||||
group: 'extensions',
|
||||
@@ -86,12 +81,9 @@ export type SettingsCategoryDefinition =
|
||||
(typeof settingsCategoryList)[number]
|
||||
export type SettingsCategoryId = SettingsCategoryDefinition['id']
|
||||
|
||||
export function getSettingsCategoryList(
|
||||
remoteProjectsEnabled: boolean
|
||||
): readonly SettingsCategoryDefinition[] {
|
||||
return remoteProjectsEnabled
|
||||
? settingsCategoryList
|
||||
: settingsCategoryList.filter(({ id }) => id !== 'ssh-hosts')
|
||||
// SSH hosts moved to the Hosts application and are no longer a settings category.
|
||||
export function getSettingsCategoryList(): readonly SettingsCategoryDefinition[] {
|
||||
return settingsCategoryList
|
||||
}
|
||||
|
||||
type LocalizedSettingsCategoryDefinition = SettingsCategoryDefinition & {
|
||||
|
||||
@@ -37,13 +37,13 @@ export const defaultSupervisorModelConcurrency = 1
|
||||
export const supervisorModelConcurrencySchema = z.number().int().min(1).max(4)
|
||||
export const supervisionTimeoutSecondsSchema = z.number().int().min(30).max(600)
|
||||
|
||||
export const builtInApplicationIds = ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing'] as const
|
||||
export const builtInApplicationIds = ['magic-notes', 'knowledge', 'heartbeat', 'local-inference', 'device-sharing', 'remote-hosts'] as const
|
||||
export type BuiltInApplicationId = typeof builtInApplicationIds[number]
|
||||
export const editableApplicationIds = ['magic-notes', 'heartbeat', 'local-inference', 'device-sharing'] as const
|
||||
export const editableApplicationIds = ['magic-notes', 'heartbeat', 'local-inference', 'device-sharing', 'remote-hosts'] as const
|
||||
export type EditableApplicationId = typeof editableApplicationIds[number]
|
||||
export const defaultApplicationNavigation = {
|
||||
order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference', 'device-sharing'] as BuiltInApplicationId[],
|
||||
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': false }
|
||||
order: ['knowledge', 'heartbeat', 'magic-notes', 'local-inference', 'device-sharing', 'remote-hosts'] as BuiltInApplicationId[],
|
||||
pinned: { 'magic-notes': true, heartbeat: true, 'local-inference': false, 'remote-hosts': false }
|
||||
}
|
||||
export const applicationNavigationSchema = z.object({
|
||||
order: z.array(z.enum(builtInApplicationIds)).length(builtInApplicationIds.length)
|
||||
@@ -51,7 +51,9 @@ export const applicationNavigationSchema = z.object({
|
||||
pinned: z.object({
|
||||
'magic-notes': z.boolean(),
|
||||
heartbeat: z.boolean().default(true),
|
||||
'local-inference': z.boolean()
|
||||
'local-inference': z.boolean(),
|
||||
// Optional on input so callers written before the application existed keep working.
|
||||
'remote-hosts': z.boolean().default(false)
|
||||
}).strict()
|
||||
}).strict()
|
||||
|
||||
|
||||
@@ -23,10 +23,14 @@ describe('GoodBuddy configuration contracts', () => {
|
||||
operation: 'application.update',
|
||||
updates: { applicationNavigation: {
|
||||
...defaultApplicationNavigation,
|
||||
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'],
|
||||
order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing', 'remote-hosts'],
|
||||
} },
|
||||
}
|
||||
expect(goodbuddyConfigOperationSchema.parse(operation)).toEqual(operation)
|
||||
// The order written before Hosts existed is no longer complete.
|
||||
expect(goodbuddyConfigOperationSchema.safeParse({ ...operation, updates: {
|
||||
applicationNavigation: { ...defaultApplicationNavigation, order: ['local-inference', 'heartbeat', 'magic-notes', 'knowledge', 'device-sharing'] },
|
||||
} }).success).toBe(false)
|
||||
for (const order of [['magic-notes', 'local-inference'], ['knowledge', 'heartbeat', 'magic-notes', 'magic-notes'], ['knowledge', 'heartbeat', 'magic-notes', 'invalid']]) {
|
||||
expect(goodbuddyConfigOperationSchema.safeParse({ ...operation, updates: {
|
||||
applicationNavigation: { ...defaultApplicationNavigation, order },
|
||||
|
||||
Reference in New Issue
Block a user