feat: add independent managed SSH agent packages

Managed SSH previously tied remote Agent and Runtime artifacts to desktop
packaging and Host activation. Settings now manages verified Linux x64 and
arm64 .gbagent packages explicitly, with source-aligned download and update
plus offline import and export. Each package contains the signed Agent, pinned
Node, and compatible OpenCode Runtime, and project activation never downloads
one automatically.

Dedicated native CI and a protected release workflow publish deterministic,
signed cumulative catalogs to GitHub and Beijing OSS without replacing the
desktop Latest release. Desktop packaging is metadata-only, remains independent
of Agent artifacts and signing credentials, and Linux desktop releases add RPM
installers.

The managed SSH path also keeps model credentials in Main, enforces Ask as
read-only, verifies signed Agent and Runtime inputs, preserves ACP recovery, and
reports Host update cancellation and failures accurately.

Release note: 远程项目现可在“设置”中手动下载、更新或离线导入签名 Agent 包,桌面安装包与远程组件发布相互独立;Linux 桌面版同时新增 RPM 安装包。
This commit is contained in:
mesalogo
2026-08-25 18:28:29 +08:00
parent 88658aceaa
commit e5aebaa67d
101 changed files with 11543 additions and 2168 deletions
+189 -2
View File
@@ -7,6 +7,7 @@ import {
within,
waitFor
} from '@testing-library/react'
import { StrictMode } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
AgentEvent,
@@ -3417,6 +3418,58 @@ describe('App', () => {
expect(screen.getByText('项目:默认项目')).toHaveClass('scope-badge')
})
it('keeps adjacent streamed text blocks exact in StrictMode', async () => {
render(
<StrictMode>
<App />
</StrictMode>
)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
target: { value: '读取验收标记' }
})
await waitFor(() => expect(screen.getByLabelText('发送')).toBeEnabled())
fireEvent.click(screen.getByLabelText('发送'))
await waitFor(() => expect(run).toHaveBeenCalledOnce())
const request = run.mock.calls[0]?.[0]
if (!request) {
throw new Error('Missing request')
}
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'text',
delta: 'GO'
})
agentListener?.({
requestId: request.requestId,
type: 'text',
delta: 'ODBUDDY_REMOTE_TOOL_4C6B9D8A'
})
})
const expected = 'GOODBUDDY_REMOTE_TOOL_4C6B9D8A'
const text = await screen.findByText(expected)
const assistantArticle = text.closest('article')
expect(assistantArticle).not.toBeNull()
expect(
assistantArticle!.querySelectorAll(
'.message-blocks > .message__content'
)
).toHaveLength(1)
expect(
assistantArticle!.querySelector('.message-blocks')
).toHaveTextContent(expected)
act(() => {
agentListener?.({
requestId: request.requestId,
type: 'done'
})
})
})
it('persists only the changed conversation and streamed assistant message', async () => {
const activeConversationId =
'00000000-0000-4000-8000-000000000441'
@@ -4657,7 +4710,11 @@ describe('App', () => {
})
it('restores a queued message when Agent preflight rejects it', async () => {
run.mockRejectedValueOnce(new Error('Runtime 暂不可用'))
run.mockRejectedValueOnce(
new Error(
"Error invoking remote method 'agent:run': Error: Runtime 暂不可用"
)
)
render(<App />)
fireEvent.change(screen.getByLabelText('向 GoodBuddy 提问'), {
@@ -4671,6 +4728,9 @@ describe('App', () => {
expect(
await screen.findByText('Runtime 暂不可用')
).toBeInTheDocument()
expect(
screen.queryByText(/Error invoking remote method/u)
).not.toBeInTheDocument()
expect(
screen.queryByText('需要稍后重试的消息')
).not.toBeInTheDocument()
@@ -5691,6 +5751,103 @@ describe('App', () => {
)
})
it('shows only supported OpenCode choices in a managed SSH conversation', async () => {
installRemoteProjectsSetting(true)
const remoteProject = {
...project,
id: '00000000-0000-4000-8000-000000000119',
name: '仅 OpenCode 远程项目',
rootPath: '/srv/project',
runtimeSelection: { provider: 'opencode' as const },
executionSpace: {
kind: 'ssh' as const,
hostId: '00000000-0000-4000-8000-000000000219',
remoteRootPath: '/srv/project'
},
builtInDefault: false
}
vi.mocked(api.projects.list).mockResolvedValueOnce([
project,
remoteProject
])
vi.mocked(api.projects.remote.activate).mockResolvedValueOnce(
remoteProject
)
vi.mocked(api.conversations.list).mockResolvedValueOnce([
{
id: '00000000-0000-4000-8000-000000000319',
projectId: remoteProject.id,
runtimeSelection: {
provider: 'model',
profileId: modelProfileId
},
title: '旧直连模型会话',
updatedAt: 1_775_000_000_000,
messages: []
},
{
id: '00000000-0000-4000-8000-000000000419',
projectId: remoteProject.id,
runtimeSelection: {
provider: 'continue',
profileId: modelProfileId
},
title: '旧 Continue 会话',
updatedAt: 1_774_000_000_000,
messages: []
}
])
render(<App />)
await screen.findByRole('button', { name: '当前项目' })
selectProjectOption(remoteProject.name)
await waitFor(() =>
expect(
screen.getByRole('button', { name: '当前项目' })
).toHaveTextContent(remoteProject.name)
)
const runtimeButton = await screen.findByRole('button', {
name: /OpenCode · 默认模型/u
})
fireEvent.click(runtimeButton)
const runtimeMenu = screen.getByRole('menu', {
name: 'Runtime 和模型'
})
expect(
within(runtimeMenu).getByRole('menuitemradio', {
name: /^OpenCode · 默认模型.*sonnet-5$/u
})
).toBeInTheDocument()
expect(
within(runtimeMenu).getAllByRole('menuitemradio')
).toHaveLength(1)
expect(
within(runtimeMenu).queryByText('直连模型')
).not.toBeInTheDocument()
expect(
within(runtimeMenu).queryByText('Continue Runtime')
).not.toBeInTheDocument()
expect(
within(runtimeMenu).queryByText(/DeepSeek Harness/u)
).not.toBeInTheDocument()
expect(
within(runtimeMenu).getByRole('menuitem', {
name: '管理 Runtime 和模型连接'
})
).toBeInTheDocument()
fireEvent.keyDown(document, { key: 'Escape' })
fireEvent.click(
screen.getByText('旧 Continue 会话').closest('button')!
)
expect(
await screen.findByRole('button', {
name: /OpenCode · 默认模型/u
})
).toBeInTheDocument()
})
it('keeps saved remote projects hidden while the preview is disabled', async () => {
installRemoteProjectsSetting(false)
const remoteProject = {
@@ -5872,7 +6029,9 @@ describe('App', () => {
remoteProject
])
vi.mocked(api.projects.remote.activate).mockRejectedValueOnce(
new Error('Agent upload failed')
new Error(
"Error invoking remote method 'projects:remote:activate': AgentInstallationError: Agent upload failed"
)
)
render(<App />)
await waitFor(() =>
@@ -5987,6 +6146,31 @@ describe('App', () => {
hosts: [host],
secureStorageAvailable: true
})),
getAgentPackageInventory: vi.fn(async () => ({
checkedAt: '2026-08-24T00:00:00.000Z',
entries: [
{
platform: 'linux' as const,
architecture: 'x64' as const,
state: 'verified' as const,
version: '0.11.2-e2e.13',
agentProtocol: { major: 2, minor: 0 },
remoteRuntimeVersion: '1.18.9'
},
{
platform: 'linux' as const,
architecture: 'arm64' as const,
state: 'not-downloaded' as const,
version: null,
agentProtocol: null,
remoteRuntimeVersion: null
}
]
})),
downloadAgentPackage: vi.fn(),
importAgentPackage: vi.fn(),
exportAgentPackage: vi.fn(async () => {}),
onAgentPackageProgress: vi.fn(() => () => {}),
remove: vi.fn(async () => undefined),
inspectDraftHostKey: vi.fn(async () => ({
candidateId,
@@ -6038,6 +6222,9 @@ describe('App', () => {
}
}]
})),
updateRemoteEnvironment: vi.fn(async () => undefined),
cancelRemoteEnvironmentUpdate: vi.fn(async () => undefined),
onRemoteEnvironmentUpdateProgress: vi.fn(() => () => undefined),
browseDirectories: vi.fn(async () => ({
path: '/root',
homeDirectory: '/root',
+214 -151
View File
@@ -215,6 +215,7 @@ import {
type KeepAliveCacheEntry
} from './keep-alive-cache'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { displayErrorMessage } from './error-message'
import { getProjectDisplayText } from './project-display'
const knowledgeWorkspaceRoute = createPreloadableComponent(
@@ -561,10 +562,13 @@ function appendMessageContentBlock(
const current = [...blocks]
const previous = current.at(-1)
if (previous?.type === type) {
previous.content = `${previous.content}${delta}`.slice(
0,
maxMessageContentLength
)
current[current.length - 1] = {
...previous,
content: `${previous.content}${delta}`.slice(
0,
maxMessageContentLength
)
}
return current
}
if (current.length >= maxMessageBlocks) {
@@ -672,19 +676,6 @@ function createConversation(
}
}
function displayErrorMessage(reason: unknown, fallback: string): string {
if (
typeof reason === 'object' &&
reason !== null &&
'message' in reason &&
typeof reason.message === 'string' &&
reason.message
) {
return reason.message
}
return fallback
}
function createConversationBranchTitle(
sourceTitle: string,
suffix: string
@@ -1415,12 +1406,32 @@ function getProjectDefaultRuntimeSelection(
project: AssistantProject | undefined,
settings: RuntimeSettings
): AgentRuntimeSelection {
if (isManagedSshProject(project)) {
return getRuntimeSelectionForProvider('opencode', settings)
}
const selection = project?.runtimeSelection
return !selection || selection.provider === 'auto'
? getDefaultRuntimeSelection(settings)
: selection
}
type ManagedSshProject = AssistantProject & {
kind: 'user'
executionSpace: Extract<
AssistantProject['executionSpace'],
{ kind: 'ssh' }
>
}
function isManagedSshProject(
project: AssistantProject | undefined
): project is ManagedSshProject {
return (
project?.kind === 'user' &&
project.executionSpace.kind === 'ssh'
)
}
function isOrdinaryLocalProject(
project: AssistantProject
): boolean {
@@ -2877,6 +2888,12 @@ function App(): React.JSX.Element {
() => conversations.find((conversation) => conversation.id === activeId),
[activeId, conversations]
)
const activeProject = useMemo(
() => projects.find((project) => project.id === activeProjectId),
[activeProjectId, projects]
)
const activeProjectUsesManagedSsh =
isManagedSshProject(activeProject)
const cachedWorkspaceViewKeys = useMemo(
() => new Set(cachedWorkspaceViews.map((entry) => entry.key)),
[cachedWorkspaceViews]
@@ -3367,10 +3384,6 @@ function App(): React.JSX.Element {
},
[notify, projects, runtimeSettings, setActiveId, setView]
)
const activeProject = useMemo(
() => projects.find((project) => project.id === activeProjectId),
[activeProjectId, projects]
)
const activeRemoteProjectRequiresActivation =
remoteProjectsRequiringActivation.has(activeProjectId)
const activeProjectDisplayName = activeProject
@@ -3681,7 +3694,13 @@ function App(): React.JSX.Element {
const switchRuntime = useCallback(
async (selection: AgentRuntimeSelection): Promise<void> => {
if (!runtimeSettings || !activeConversation || runtimeSwitching) {
if (
!runtimeSettings ||
!activeConversation ||
runtimeSwitching ||
(activeProjectUsesManagedSsh &&
selection.provider !== 'opencode')
) {
return
}
runtimeMenuButtonRef.current?.focus()
@@ -3751,6 +3770,7 @@ function App(): React.JSX.Element {
},
[
activeConversation,
activeProjectUsesManagedSsh,
runtimeLabels,
runtimeSettings,
runtimeSwitching
@@ -4936,10 +4956,10 @@ function App(): React.JSX.Element {
})
return { project, current: false }
}
const message =
reason instanceof Error && reason.message
? reason.message
: tRef.current('notices.remoteProjectActivationUnknownError')
const message = displayErrorMessage(
reason,
tRef.current('notices.remoteProjectActivationUnknownError')
)
throw new Error(
tRef.current('notices.remoteProjectActivationFailed', {
message
@@ -4985,15 +5005,17 @@ function App(): React.JSX.Element {
)
notify({
tone: 'error',
message:
reason instanceof Error && reason.message
? tRef.current(
'notices.remoteProjectActivationCancelFailed',
{ message: reason.message }
)
: tRef.current(
message: tRef.current(
'notices.remoteProjectActivationCancelFailed',
{
message: displayErrorMessage(
reason,
tRef.current(
'notices.remoteProjectActivationCancelUnknownError'
)
)
}
)
})
}
)
@@ -5794,6 +5816,26 @@ function App(): React.JSX.Element {
)
)
if (conversation) {
if (isManagedSshProject(selected)) {
const runtimeSelection = runtimeSettings
? getRuntimeSelectionForProvider(
'opencode',
runtimeSettings
)
: ({ provider: 'opencode' } as const)
setConversations((current) =>
current.map((candidate) =>
candidate.projectId === selected.id &&
candidate.runtimeSelection?.provider !== 'opencode'
? {
...candidate,
runtimeSelection,
updatedAt: Date.now()
}
: candidate
)
)
}
setActiveId(conversation.id)
} else if (selected.kind === 'channel') {
setActiveId('')
@@ -6729,10 +6771,10 @@ function App(): React.JSX.Element {
} catch (reason) {
notify({
tone: 'error',
message:
reason instanceof Error
? reason.message
: t('notices.sendFailed')
message: displayErrorMessage(
reason,
t('notices.sendFailed')
)
})
}
return
@@ -6919,8 +6961,10 @@ function App(): React.JSX.Element {
)
notify({
tone: 'error',
message:
error instanceof Error ? error.message : t('notices.sendFailed')
message: displayErrorMessage(
error,
t('notices.sendFailed')
)
})
await releaseQueuedItem()
} finally {
@@ -9214,49 +9258,55 @@ function App(): React.JSX.Element {
ref={runtimeMenuRef}
role="menu"
>
<strong
role="presentation"
>
{t('runtime.directModels')}
</strong>
{runtimeSettings?.modelProfiles.map((profile) => (
<button
aria-checked={
activeRuntimeSelectionKey ===
`model:${profile.id}`
}
key={profile.id}
onClick={() =>
void switchRuntime({
provider: 'model',
profileId: profile.id
})
}
role="menuitemradio"
tabIndex={
activeRuntimeSelectionKey ===
`model:${profile.id}`
? 0
: -1
}
type="button"
>
<span>
{profile.name}
{profile.protocol ===
'openai-images-generations' && (
<span className="runtime-capability-badge">
{t('runtime.imageGeneration')}
</span>
{!activeProjectUsesManagedSsh && (
<>
<strong role="presentation">
{t('runtime.directModels')}
</strong>
{runtimeSettings?.modelProfiles.map(
(profile) => (
<button
aria-checked={
activeRuntimeSelectionKey ===
`model:${profile.id}`
}
key={profile.id}
onClick={() =>
void switchRuntime({
provider: 'model',
profileId: profile.id
})
}
role="menuitemradio"
tabIndex={
activeRuntimeSelectionKey ===
`model:${profile.id}`
? 0
: -1
}
type="button"
>
<span>
{profile.name}
{profile.protocol ===
'openai-images-generations' && (
<span className="runtime-capability-badge">
{t(
'runtime.imageGeneration'
)}
</span>
)}
</span>
<small>{profile.modelName}</small>
</button>
)
)}
</span>
<small>{profile.modelName}</small>
</button>
))}
<div
className="runtime-picker__divider"
role="separator"
/>
<div
className="runtime-picker__divider"
role="separator"
/>
</>
)}
<strong
role="presentation"
>
@@ -9284,76 +9334,89 @@ function App(): React.JSX.Element {
<small>{openCodeMenuSource.detail}</small>
</button>
)}
<div
className="runtime-picker__divider"
role="separator"
/>
<strong
role="presentation"
>
Continue Runtime
</strong>
{continueMenuSelection && continueMenuSource && (
<button
aria-checked={
activeRuntimeSelectionKey ===
agentRuntimeSelectionKey(continueMenuSelection)
}
onClick={() =>
void switchRuntime(continueMenuSelection)
}
role="menuitemradio"
tabIndex={
activeRuntimeSelectionKey ===
agentRuntimeSelectionKey(continueMenuSelection)
? 0
: -1
}
type="button"
>
<span>{continueMenuSource.label}</span>
<small>{continueMenuSource.detail}</small>
</button>
)}
<div
className="runtime-picker__divider"
role="separator"
/>
<strong role="presentation">
{t('runtime.deepseekHarnessGroup')}
</strong>
{deepseekHarnessMenuSelection &&
deepseekHarnessMenuSource && (
<button
aria-checked={
activeRuntimeSelectionKey ===
agentRuntimeSelectionKey(
deepseekHarnessMenuSelection
)
}
onClick={() =>
void switchRuntime(
deepseekHarnessMenuSelection
)
}
role="menuitemradio"
tabIndex={
activeRuntimeSelectionKey ===
agentRuntimeSelectionKey(
deepseekHarnessMenuSelection
)
? 0
: -1
}
type="button"
>
<span>
{deepseekHarnessMenuSource.label}
</span>
<small>
{deepseekHarnessMenuSource.detail}
</small>
</button>
{!activeProjectUsesManagedSsh && (
<>
<div
className="runtime-picker__divider"
role="separator"
/>
<strong role="presentation">
Continue Runtime
</strong>
{continueMenuSelection &&
continueMenuSource && (
<button
aria-checked={
activeRuntimeSelectionKey ===
agentRuntimeSelectionKey(
continueMenuSelection
)
}
onClick={() =>
void switchRuntime(
continueMenuSelection
)
}
role="menuitemradio"
tabIndex={
activeRuntimeSelectionKey ===
agentRuntimeSelectionKey(
continueMenuSelection
)
? 0
: -1
}
type="button"
>
<span>
{continueMenuSource.label}
</span>
<small>
{continueMenuSource.detail}
</small>
</button>
)}
<div
className="runtime-picker__divider"
role="separator"
/>
<strong role="presentation">
{t('runtime.deepseekHarnessGroup')}
</strong>
{deepseekHarnessMenuSelection &&
deepseekHarnessMenuSource && (
<button
aria-checked={
activeRuntimeSelectionKey ===
agentRuntimeSelectionKey(
deepseekHarnessMenuSelection
)
}
onClick={() =>
void switchRuntime(
deepseekHarnessMenuSelection
)
}
role="menuitemradio"
tabIndex={
activeRuntimeSelectionKey ===
agentRuntimeSelectionKey(
deepseekHarnessMenuSelection
)
? 0
: -1
}
type="button"
>
<span>
{deepseekHarnessMenuSource.label}
</span>
<small>
{deepseekHarnessMenuSource.detail}
</small>
</button>
)}
</>
)}
<div
className="runtime-picker__divider"
@@ -30,6 +30,7 @@ import type {
} from '../../shared/document-parsing-contracts'
import type { AppNotificationInput } from './notifications'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { displayErrorMessage } from './error-message'
import {
SettingsCategoryHeader,
SettingsWarningList
@@ -41,16 +42,6 @@ type DocumentParsingSettingsSectionProps = {
onDirtyChange?: (dirty: boolean) => void
}
function errorMessage(reason: unknown, fallback: string): string {
if (!(reason instanceof Error)) {
return fallback
}
return reason.message.replace(
/^Error invoking remote method '[^']+': (?:Error: )?/u,
''
)
}
function formatBytes(bytes: number): string {
return bytes >= 1024 * 1024
? `${(bytes / (1024 * 1024)).toFixed(1)} MB`
@@ -292,7 +283,10 @@ export function DocumentParsingSettingsSection({
(reason: unknown) => {
if (active) {
setError(
errorMessage(reason, t('errors.readDocumentParsing'))
displayErrorMessage(
reason,
t('errors.readDocumentParsing')
)
)
}
}
@@ -361,7 +355,7 @@ export function DocumentParsingSettingsSection({
return next
} catch (reason) {
setError(
errorMessage(reason, t('errors.saveDocumentParsing'))
displayErrorMessage(reason, t('errors.saveDocumentParsing'))
)
return undefined
} finally {
@@ -390,7 +384,7 @@ export function DocumentParsingSettingsSection({
} catch (reason) {
if (mountedRef.current) {
setError(
errorMessage(
displayErrorMessage(
reason,
t('errors.manageDocumentOcrModel')
)
@@ -442,7 +436,7 @@ export function DocumentParsingSettingsSection({
}
} catch (reason) {
setError(
errorMessage(reason, t('errors.testDocumentParsing'))
displayErrorMessage(reason, t('errors.testDocumentParsing'))
)
} finally {
setTestingPurpose(undefined)
@@ -1,5 +1,17 @@
import { RotateCcw, Save } from 'lucide-react'
import { useEffect, useState } from 'react'
import {
Cpu,
Download,
RefreshCw,
RotateCcw,
Save,
Upload
} from 'lucide-react'
import {
useCallback,
useEffect,
useRef,
useState
} from 'react'
import { useTranslation } from 'react-i18next'
import type {
ApplicationSettings,
@@ -7,6 +19,13 @@ import type {
ModelDownloadSource
} from '../../shared/application-settings-contracts'
import type { MagicNoteCommentFormat } from '../../shared/magic-notes-contracts'
import type {
AgentPackageDownloadProgress,
AgentPackageInventory
} from '../../shared/agent-package-contracts'
import type {
AgentArchitecture
} from '../../shared/agent-installation-contracts'
import {
canonicalizeShortcutAccelerator,
type GlobalShortcutSettings,
@@ -22,6 +41,7 @@ import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
import { displayErrorMessage } from './error-message'
type PlatformFeaturesSettingsSectionProps = {
onMagicNotesEnabledChange: (enabled: boolean) => void
@@ -78,6 +98,17 @@ export function PlatformFeaturesSettingsSection({
)
const [saving, setSaving] = useState(false)
const [sourceError, setSourceError] = useState<string>()
const [agentInventory, setAgentInventory] =
useState<AgentPackageInventory>()
const [agentInventoryLoading, setAgentInventoryLoading] =
useState(false)
const [agentInventoryError, setAgentInventoryError] =
useState<string>()
const [agentPackageBusy, setAgentPackageBusy] =
useState<AgentArchitecture | 'import'>()
const [agentPackageProgress, setAgentPackageProgress] =
useState<AgentPackageDownloadProgress>()
const agentInventoryRequested = useRef(false)
const [error, setError] = useState<string | undefined>(() =>
window.goodbuddy.updates
? undefined
@@ -109,6 +140,160 @@ 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
): Promise<void> => {
const download =
window.goodbuddy.sshHosts?.downloadAgentPackage
if (!download) {
return
}
setAgentPackageBusy(architecture)
setAgentPackageProgress(undefined)
setAgentInventoryError(undefined)
try {
setAgentInventory(await download(architecture))
onNotify?.({
dedupeKey: `agent-package-downloaded:${architecture}`,
message: t(
'platformFeatures.remoteProjects.agentInventory.notifications.downloaded',
{ architecture }
),
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
): Promise<void> => {
const exportPackage =
window.goodbuddy.sshHosts?.exportAgentPackage
if (!exportPackage) {
return
}
setAgentPackageBusy(architecture)
setAgentInventoryError(undefined)
try {
await exportPackage(architecture)
onNotify?.({
dedupeKey: `agent-package-exported:${architecture}`,
message: t(
'platformFeatures.remoteProjects.agentInventory.notifications.exported',
{ architecture }
),
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
@@ -649,6 +834,197 @@ export function PlatformFeaturesSettingsSection({
</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.architecture}
role="listitem"
>
<div className="bundled-agent-inventory__item-header">
<Cpu aria-hidden="true" size={16} />
<strong>
Linux {entry.architecture}
</strong>
<span
className={`bundled-agent-inventory__badge bundled-agent-inventory__badge--${entry.state}`}
>
{t(
`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.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 &&
agentPackageBusy === entry.architecture && (
<p className="settings-notice" role="status">
{t(
`platformFeatures.remoteProjects.agentInventory.progress.${agentPackageProgress.phase}`
)}
</p>
)}
<div className="capability-card__actions">
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() =>
void downloadAgentPackage(
entry.architecture
)
}
type="button"
>
<Download aria-hidden="true" size={13} />
{t(
entry.state === 'verified'
? 'platformFeatures.remoteProjects.agentInventory.update'
: 'platformFeatures.remoteProjects.agentInventory.download'
)}
</button>
{entry.state === 'verified' && (
<button
className="secondary-button"
disabled={agentPackageBusy !== undefined}
onClick={() =>
void exportAgentPackage(
entry.architecture
)
}
type="button"
>
<Upload aria-hidden="true" size={13} />
{t(
'platformFeatures.remoteProjects.agentInventory.export'
)}
</button>
)}
</div>
</article>
))}
</div>
) : null}
</article>
</section>
<section
+19 -15
View File
@@ -44,7 +44,7 @@ import { getProjectDisplayText } from './project-display'
import { ProjectRuntimeSelector } from './ProjectRuntimeSelector'
import { ProjectWorkModeFields } from './ProjectWorkModeFields'
import { SegmentedControl } from './WorkspacePrimitives'
import { sshHostErrorMessage } from './ssh-host-ui'
import { displayErrorMessage } from './error-message'
type ProjectSwitcherProps = {
projects: AssistantProject[]
@@ -421,7 +421,7 @@ export function ProjectSwitcher({
if (requestId !== directoryBrowseRequestRef.current) {
return
}
const message = sshHostErrorMessage(
const message = displayErrorMessage(
reason,
t('projectSwitcher.remote.directoryPicker.unknownError')
)
@@ -605,7 +605,7 @@ export function ProjectSwitcher({
closeDialog()
} catch (reason) {
setError(
sshHostErrorMessage(
displayErrorMessage(
reason,
t('projectSwitcher.remote.errors.save')
)
@@ -635,11 +635,12 @@ export function ProjectSwitcher({
closeDialog()
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: dialogMode === 'settings'
displayErrorMessage(
reason,
dialogMode === 'settings'
? t('projectSwitcher.errors.save')
: t('projectSwitcher.errors.create')
)
)
} finally {
setSaving(false)
@@ -658,9 +659,10 @@ export function ProjectSwitcher({
}
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: t('projectSwitcher.errors.selectRoot')
displayErrorMessage(
reason,
t('projectSwitcher.errors.selectRoot')
)
)
}
}
@@ -673,9 +675,10 @@ export function ProjectSwitcher({
closeDialog()
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: t('projectSwitcher.errors.archive')
displayErrorMessage(
reason,
t('projectSwitcher.errors.archive')
)
)
} finally {
setArchiving(false)
@@ -693,9 +696,10 @@ export function ProjectSwitcher({
closeDialog()
} catch (reason) {
setError(
reason instanceof Error
? reason.message
: t('projectSwitcher.errors.delete')
displayErrorMessage(
reason,
t('projectSwitcher.errors.delete')
)
)
} finally {
setDeleting(false)
+158
View File
@@ -24,6 +24,9 @@ import type {
CapabilitySnapshot
} from '../../shared/capability-contracts'
import type { ApplicationSettings } from '../../shared/application-settings-contracts'
import type {
AgentPackageInventory
} from '../../shared/agent-package-contracts'
import type {
GlobalShortcutSettingsSnapshot
} from '../../shared/shortcut'
@@ -468,6 +471,34 @@ let applicationSettings: ApplicationSettings = {
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
}
const agentPackageInventory: AgentPackageInventory = {
checkedAt: '2026-08-24T00:00:00.000Z',
entries: [
{
platform: 'linux',
architecture: 'x64',
state: 'verified',
version: '0.11.2-e2e.13',
agentProtocol: { major: 2, minor: 0 },
remoteRuntimeVersion: '1.18.9'
},
{
platform: 'linux',
architecture: 'arm64',
state: 'not-downloaded',
version: null,
agentProtocol: null,
remoteRuntimeVersion: null
}
]
}
const getAgentPackageInventory = vi.fn<
NonNullable<DesktopApi['sshHosts']>['getAgentPackageInventory']
>(async (): Promise<AgentPackageInventory> => agentPackageInventory)
const downloadAgentPackage = vi.fn(async () => agentPackageInventory)
const importAgentPackage = vi.fn(async () => agentPackageInventory)
const exportAgentPackage = vi.fn(async () => {})
const onAgentPackageProgress = vi.fn(() => () => {})
const getApplicationSettings = vi.fn(async () => ({
...applicationSettings
}))
@@ -691,6 +722,10 @@ describe('SettingsPanel runtime files', () => {
magicNoteCommentMode: 'immediate',
magicNoteCommentFormat: 'combined'
}
getAgentPackageInventory.mockReset()
getAgentPackageInventory.mockResolvedValue(
agentPackageInventory
)
speechModelSnapshot = createSpeechModelSnapshot()
shortcutSettingsSnapshot = {
settings: {
@@ -757,6 +792,11 @@ describe('SettingsPanel runtime files', () => {
hosts: [],
secureStorageAvailable: true
})),
getAgentPackageInventory,
downloadAgentPackage,
importAgentPackage,
exportAgentPackage,
onAgentPackageProgress,
create: vi.fn(),
update: vi.fn(),
remove: vi.fn(async () => undefined),
@@ -1781,12 +1821,72 @@ describe('SettingsPanel runtime files', () => {
name: '远程项目(技术预览)'
})
).not.toBeInTheDocument()
expect(getAgentPackageInventory).not.toHaveBeenCalled()
fireEvent.click(remoteProjectsTab)
expect(remoteProjectsTab).toHaveAttribute('aria-selected', 'true')
const remoteProjectsSwitch = await screen.findByRole('switch', {
name: '远程项目(技术预览)'
})
expect(remoteProjectsSwitch).not.toBeChecked()
expect(
await screen.findByText('GoodBuddy Agent 包')
).toBeInTheDocument()
expect(
screen.getByText('当前 1 / 2 个 Linux 架构可用。')
).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()
fireEvent.click(
within(x64Card as HTMLElement).getByRole('button', {
name: '检查并更新'
})
)
await waitFor(() =>
expect(downloadAgentPackage).toHaveBeenCalledWith('x64')
)
fireEvent.click(
within(x64Card as HTMLElement).getByRole('button', {
name: '导出离线包'
})
)
await waitFor(() =>
expect(exportAgentPackage).toHaveBeenCalledWith('x64')
)
fireEvent.click(
within(arm64Card as HTMLElement).getByRole('button', {
name: '下载'
})
)
await waitFor(() =>
expect(downloadAgentPackage).toHaveBeenCalledWith('arm64')
)
fireEvent.click(
screen.getByRole('button', { name: '导入离线包' })
)
await waitFor(() =>
expect(importAgentPackage).toHaveBeenCalledOnce()
)
fireEvent.click(remoteProjectsSwitch)
@@ -1799,6 +1899,64 @@ describe('SettingsPanel runtime files', () => {
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(
await screen.findByRole('tab', {
name: '远程项目(技术预览)'
})
)
expect(
await screen.findByText('正在校验本地 Agent 包…')
).toBeInTheDocument()
await act(async () => {
resolveInventory?.(agentPackageInventory)
await Promise.resolve()
})
expect(
await screen.findByText('当前 1 / 2 个 Linux 架构可用。')
).toBeInTheDocument()
getAgentPackageInventory.mockRejectedValueOnce(
new Error('inventory unavailable')
)
fireEvent.click(
screen.getByRole('button', { name: '刷新本地清单' })
)
expect(
await screen.findByText('inventory unavailable')
).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', { name: '刷新本地清单' })
)
await waitFor(() =>
expect(
screen.queryByText('inventory unavailable')
).not.toBeInTheDocument()
)
expect(getAgentPackageInventory).toHaveBeenCalledTimes(3)
})
it('shows the SSH Hosts category only while Remote Projects is enabled', () => {
const commonProps = {
...heartbeatSettingsProps,
+2 -7
View File
@@ -49,6 +49,7 @@ import { PlatformFeaturesSettingsSection } from './PlatformFeaturesSettingsSecti
import { SpeechModelSettingsSection } from './SpeechModelSettingsSection'
import { DocumentParsingSettingsSection } from './DocumentParsingSettingsSection'
import { DshMarketplaceSection } from './DshMarketplaceSection'
import { displayErrorMessage } from './error-message'
import { SshHostsSettingsSection } from './SshHostsSettingsSection'
import {
RuntimeCustomizationSection,
@@ -232,13 +233,7 @@ type SettingsPanelProps = {
}
function settingsErrorMessage(reason: unknown, fallback: string): string {
if (!(reason instanceof Error)) {
return fallback
}
const message = reason.message.replace(
/^Error invoking remote method '[^']+': (?:Error: )?/u,
''
)
const message = displayErrorMessage(reason, fallback)
try {
const issues: unknown = JSON.parse(message)
if (
+3 -3
View File
@@ -19,7 +19,7 @@ import type {
SshHostValidationResult
} from '../../shared/ssh-host-contracts'
import { activateModalFocus, trapTabFocus } from './dialog-focus'
import { sshHostErrorMessage } from './ssh-host-ui'
import { displayErrorMessage } from './error-message'
type HostDraft = {
name: string
@@ -254,7 +254,7 @@ export function SshHostDialog({
setStage('host-key')
} catch (reason) {
setError(
sshHostErrorMessage(
displayErrorMessage(
reason,
t('sshHosts.errors.inspectFailed')
)
@@ -325,7 +325,7 @@ export function SshHostDialog({
})
} catch (reason) {
setError(
sshHostErrorMessage(
displayErrorMessage(
reason,
t('sshHosts.errors.validationFailed')
)
@@ -1,4 +1,5 @@
import {
act,
cleanup,
fireEvent,
render,
@@ -8,6 +9,7 @@ import {
} from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type {
RemoteEnvironmentUpdateProgress,
SshHost,
SshHostRemoteEnvironment,
SshHostValidationResult,
@@ -71,6 +73,22 @@ const discardCandidate = vi.fn()
const validateAndSave = vi.fn()
const getRemoteEnvironment =
vi.fn<(hostId: string) => Promise<SshHostRemoteEnvironment>>()
const updateRemoteEnvironment =
vi.fn<(hostId: string) => Promise<void>>()
const cancelRemoteEnvironmentUpdate =
vi.fn<(hostId: string) => Promise<void>>()
let remoteEnvironmentUpdateProgressListener:
| ((event: RemoteEnvironmentUpdateProgress) => void)
| undefined
const unsubscribeRemoteEnvironmentUpdateProgress = vi.fn()
const onRemoteEnvironmentUpdateProgress = vi.fn(
(
listener: (event: RemoteEnvironmentUpdateProgress) => void
): (() => void) => {
remoteEnvironmentUpdateProgressListener = listener
return unsubscribeRemoteEnvironmentUpdateProgress
}
)
const remoteEnvironment: SshHostRemoteEnvironment = {
hostId,
@@ -102,6 +120,29 @@ const remoteEnvironment: SshHostRemoteEnvironment = {
}]
}
const currentRemoteEnvironment: SshHostRemoteEnvironment = {
...remoteEnvironment,
agent: {
...remoteEnvironment.agent,
state: 'current',
installed: remoteEnvironment.agent.expected
}
}
function deferred<T>(): {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason: unknown) => void
} {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((resolvePromise, rejectPromise) => {
resolve = resolvePromise
reject = rejectPromise
})
return { promise, resolve, reject }
}
function openNewHostDialog(): void {
fireEvent.click(
screen.getAllByRole('button', { name: '添加主机' })[0]!
@@ -155,6 +196,17 @@ describe('SshHostsSettingsSection', () => {
discardCandidate.mockResolvedValue(undefined)
validateAndSave.mockResolvedValue(validationResult)
getRemoteEnvironment.mockResolvedValue(remoteEnvironment)
updateRemoteEnvironment.mockResolvedValue(undefined)
cancelRemoteEnvironmentUpdate.mockResolvedValue(undefined)
remoteEnvironmentUpdateProgressListener = undefined
onRemoteEnvironmentUpdateProgress.mockImplementation(
(
listener: (event: RemoteEnvironmentUpdateProgress) => void
): (() => void) => {
remoteEnvironmentUpdateProgressListener = listener
return unsubscribeRemoteEnvironmentUpdateProgress
}
)
Object.defineProperty(window, 'goodbuddy', {
configurable: true,
value: {
@@ -164,7 +216,10 @@ describe('SshHostsSettingsSection', () => {
inspectDraftHostKey,
discardCandidate,
validateAndSave,
getRemoteEnvironment
getRemoteEnvironment,
updateRemoteEnvironment,
cancelRemoteEnvironmentUpdate,
onRemoteEnvironmentUpdateProgress
}
}
})
@@ -574,4 +629,411 @@ describe('SshHostsSettingsSection', () => {
)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
})
it('places Update immediately before Refresh and disables it when versions are current', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
getRemoteEnvironment.mockResolvedValue(currentRemoteEnvironment)
render(<SshHostsSettingsSection />)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
const host = screen.getByRole('region', { name: 'Build host' })
const updateButton = within(host).getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
})
const refreshButton = within(host).getByRole('button', {
name: '刷新 Build host 的远程运行环境版本'
})
expect(updateButton.nextElementSibling).toBe(refreshButton)
expect(updateButton).toBeDisabled()
expect(updateRemoteEnvironment).not.toHaveBeenCalled()
})
it('updates with phase progress, preserves cards, refreshes, and reports success once', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
const updateOperation = deferred<void>()
updateRemoteEnvironment.mockReturnValue(updateOperation.promise)
const onHostUpdated = vi.fn()
const onNotify = vi.fn()
render(
<SshHostsSettingsSection
onHostUpdated={onHostUpdated}
onNotify={onNotify}
/>
)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
const host = screen.getByRole('region', { name: 'Build host' })
fireEvent.click(
within(host).getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
})
)
expect(updateRemoteEnvironment).toHaveBeenCalledOnce()
expect(updateRemoteEnvironment).toHaveBeenCalledWith(hostId)
expect(within(host).getByText('GoodBuddy Agent')).toBeInTheDocument()
expect(within(host).getByText('OpenCode Runtime')).toBeInTheDocument()
expect(
within(host).getByRole('button', {
name: '刷新 Build host 的远程运行环境版本'
})
).toBeDisabled()
expect(within(host).getByRole('status')).toHaveTextContent(
'正在准备更新'
)
act(() => {
remoteEnvironmentUpdateProgressListener?.({
hostId: candidateId,
phase: 'agent'
})
})
expect(within(host).getByRole('status')).toHaveTextContent(
'正在准备更新'
)
act(() => {
remoteEnvironmentUpdateProgressListener?.({
hostId,
phase: 'agent'
})
})
expect(within(host).getByRole('status')).toHaveTextContent(
'正在更新 GoodBuddy Agent'
)
act(() => {
remoteEnvironmentUpdateProgressListener?.({
hostId,
phase: 'runtime'
})
})
expect(within(host).getByRole('status')).toHaveTextContent(
'正在更新 Runtime'
)
act(() => {
remoteEnvironmentUpdateProgressListener?.({
hostId,
phase: 'finalizing'
})
})
expect(within(host).getByRole('status')).toHaveTextContent(
'正在完成远程运行环境更新'
)
expect(
within(host).getByRole('button', {
name: '取消 Build host 的远程运行环境更新'
})
).toBeDisabled()
await act(async () => {
updateOperation.resolve(undefined)
await updateOperation.promise
})
await waitFor(() =>
expect(getRemoteEnvironment).toHaveBeenCalledTimes(2)
)
await waitFor(() =>
expect(
within(host).queryByText('正在完成远程运行环境更新')
).not.toBeInTheDocument()
)
expect(onHostUpdated).toHaveBeenCalledOnce()
expect(onHostUpdated).toHaveBeenCalledWith(hostId)
expect(onNotify).toHaveBeenCalledOnce()
expect(onNotify).toHaveBeenCalledWith({
dedupeKey: `ssh-host-environment-updated:${hostId}`,
message: 'SSH 主机“Build host”的远程运行环境已更新',
tone: 'success'
})
})
it('reports a successful environment update after the settings section unmounts', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
const updateOperation = deferred<void>()
updateRemoteEnvironment.mockReturnValue(updateOperation.promise)
const onHostUpdated = vi.fn()
const onNotify = vi.fn()
const { unmount } = render(
<SshHostsSettingsSection
onHostUpdated={onHostUpdated}
onNotify={onNotify}
/>
)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
})
)
expect(updateRemoteEnvironment).toHaveBeenCalledWith(hostId)
unmount()
await act(async () => {
updateOperation.resolve()
await updateOperation.promise
})
expect(onHostUpdated).toHaveBeenCalledOnce()
expect(onHostUpdated).toHaveBeenCalledWith(hostId)
expect(onNotify).toHaveBeenCalledOnce()
})
it('reports a failed environment update after the settings section unmounts', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
const updateOperation = deferred<void>()
updateRemoteEnvironment.mockReturnValue(updateOperation.promise)
const onHostUpdated = vi.fn()
const onNotify = vi.fn()
const { unmount } = render(
<SshHostsSettingsSection
onHostUpdated={onHostUpdated}
onNotify={onNotify}
/>
)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
})
)
unmount()
await act(async () => {
updateOperation.reject(new Error('Runtime finalization failed'))
await updateOperation.promise.catch(() => undefined)
})
expect(onHostUpdated).not.toHaveBeenCalled()
expect(onNotify).toHaveBeenCalledWith({
dedupeKey:
`ssh-host-environment-update-failed:${hostId}`,
message: 'Runtime finalization failed',
tone: 'error'
})
})
it('prevents conflicting Host actions while an update is active', async () => {
const secondHost = {
...verifiedHost,
id: candidateId,
name: 'Deploy host'
}
getSnapshot.mockResolvedValue({
hosts: [verifiedHost, secondHost],
secureStorageAvailable: true
})
getRemoteEnvironment.mockImplementation(async (requestedHostId) => ({
...remoteEnvironment,
hostId: requestedHostId
}))
const updateOperation = deferred<void>()
updateRemoteEnvironment.mockReturnValue(updateOperation.promise)
render(<SshHostsSettingsSection />)
await waitFor(() =>
expect(getRemoteEnvironment).toHaveBeenCalledTimes(2)
)
const buildHost = screen.getByRole('region', {
name: 'Build host'
})
const deployHost = screen.getByRole('region', {
name: 'Deploy host'
})
fireEvent.click(
within(buildHost).getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
})
)
expect(
within(buildHost).getByRole('button', {
name: '编辑 Build host'
})
).toBeDisabled()
expect(
within(buildHost).getByRole('button', { name: '删除' })
).toBeDisabled()
expect(
within(deployHost).getByRole('button', {
name: '更新 Deploy host 的远程运行环境版本'
})
).toBeDisabled()
await act(async () => {
updateOperation.resolve(undefined)
await updateOperation.promise
})
})
it('keeps an update failure retryable, strips IPC prefixes, and refreshes partial state', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
getRemoteEnvironment
.mockResolvedValueOnce(remoteEnvironment)
.mockResolvedValue(currentRemoteEnvironment)
updateRemoteEnvironment.mockRejectedValue(
new Error(
"Error invoking remote method 'ssh-hosts:update-remote-environment': AgentInstallationError: Agent deployment failed"
)
)
const onHostUpdated = vi.fn()
const onNotify = vi.fn()
render(
<SshHostsSettingsSection
onHostUpdated={onHostUpdated}
onNotify={onNotify}
/>
)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
})
)
const alert = await screen.findByRole('alert')
expect(alert).toHaveTextContent('Agent deployment failed')
expect(alert).not.toHaveTextContent('Error invoking remote method')
expect(alert).not.toHaveTextContent('AgentInstallationError')
await waitFor(() =>
expect(getRemoteEnvironment).toHaveBeenCalledTimes(2)
)
expect(
screen.getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
})
).toBeEnabled()
expect(onHostUpdated).not.toHaveBeenCalled()
expect(onNotify).not.toHaveBeenCalled()
})
it('cancels an update, stays disabled while cancelling, and refreshes after settlement', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
const updateOperation = deferred<void>()
updateRemoteEnvironment.mockReturnValue(updateOperation.promise)
const onHostUpdated = vi.fn()
const onNotify = vi.fn()
render(
<SshHostsSettingsSection
onHostUpdated={onHostUpdated}
onNotify={onNotify}
/>
)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
})
)
const cancelButton = screen.getByRole('button', {
name: '取消 Build host 的远程运行环境更新'
})
fireEvent.click(cancelButton)
expect(cancelRemoteEnvironmentUpdate).toHaveBeenCalledOnce()
expect(cancelRemoteEnvironmentUpdate).toHaveBeenCalledWith(hostId)
expect(cancelButton).toBeDisabled()
expect(cancelButton).toHaveTextContent('正在取消…')
expect(screen.getByRole('status')).toHaveTextContent(
'正在取消远程运行环境更新'
)
await act(async () => {
updateOperation.reject(
new Error(
"Error invoking remote method 'ssh-hosts:update-remote-environment': Error: 更新已取消"
)
)
await Promise.resolve()
})
expect(await screen.findByRole('alert')).toHaveTextContent(
'更新已取消'
)
await waitFor(() =>
expect(getRemoteEnvironment).toHaveBeenCalledTimes(2)
)
expect(onHostUpdated).not.toHaveBeenCalled()
expect(onNotify).not.toHaveBeenCalled()
})
it('keeps a real update failure visible when it races with cancellation', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
const updateOperation = deferred<void>()
updateRemoteEnvironment.mockReturnValue(updateOperation.promise)
render(<SshHostsSettingsSection />)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
})
)
fireEvent.click(
screen.getByRole('button', {
name: '取消 Build host 的远程运行环境更新'
})
)
await act(async () => {
updateOperation.reject(
new Error(
"Error invoking remote method 'ssh-hosts:update-remote-environment': AgentInstallationError: Agent deployment failed"
)
)
await Promise.resolve()
})
expect(await screen.findByRole('alert')).toHaveTextContent(
'Agent deployment failed'
)
expect(screen.getByRole('alert')).not.toHaveTextContent(
'更新已取消'
)
})
it('allows cancellation to be retried when the cancel request fails', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
const updateOperation = deferred<void>()
updateRemoteEnvironment.mockReturnValue(updateOperation.promise)
cancelRemoteEnvironmentUpdate.mockRejectedValueOnce(
new Error(
"Error invoking remote method 'ssh-hosts:cancel-update': Error: Cancel request failed"
)
)
render(<SshHostsSettingsSection />)
expect(await screen.findByText('GoodBuddy Agent')).toBeInTheDocument()
fireEvent.click(
screen.getByRole('button', {
name: '更新 Build host 的远程运行环境版本'
})
)
const cancelButton = screen.getByRole('button', {
name: '取消 Build host 的远程运行环境更新'
})
fireEvent.click(cancelButton)
expect(await screen.findByRole('alert')).toHaveTextContent(
'Cancel request failed'
)
await waitFor(() => expect(cancelButton).toBeEnabled())
expect(cancelButton).toHaveTextContent('取消更新')
await act(async () => {
updateOperation.resolve(undefined)
await updateOperation.promise
})
await waitFor(() =>
expect(screen.queryByRole('alert')).not.toBeInTheDocument()
)
})
it('subscribes to update progress once and unsubscribes on unmount', async () => {
getSnapshot.mockResolvedValue(verifiedSnapshot)
const { unmount } = render(<SshHostsSettingsSection />)
expect(onRemoteEnvironmentUpdateProgress).toHaveBeenCalledOnce()
unmount()
expect(unsubscribeRemoteEnvironmentUpdateProgress).toHaveBeenCalledOnce()
})
})
+408 -31
View File
@@ -2,12 +2,14 @@ import {
Bot,
Boxes,
CheckCircle2,
Download,
LoaderCircle,
Pencil,
Plus,
RefreshCw,
Server,
ShieldCheck
ShieldCheck,
X
} from 'lucide-react'
import {
useCallback,
@@ -17,6 +19,7 @@ import {
} from 'react'
import { useTranslation } from 'react-i18next'
import type {
RemoteEnvironmentUpdatePhase,
SshHost,
SshHostConnectionTestResult,
SshHostRemoteEnvironment,
@@ -25,7 +28,7 @@ import type {
} from '../../shared/ssh-host-contracts'
import type { AppNotificationInput } from './notifications'
import { SettingsCategoryHeader } from './SettingsPrimitives'
import { sshHostErrorMessage } from './ssh-host-ui'
import { displayErrorMessage } from './error-message'
import { SshHostDialog } from './SshHostDialog'
import {
DestructiveConfirmActions,
@@ -44,6 +47,20 @@ type RemoteEnvironmentLoadState = {
error?: string
}
type RemoteEnvironmentUpdateState = {
hostId: string
requestId: number
phase?: RemoteEnvironmentUpdatePhase
cancelling: boolean
}
type ActiveRemoteEnvironmentUpdate = {
hostId: string
requestId: number
cancelRequested: boolean
cancelError?: string
}
function credentialDescription(
host: SshHost,
t: ReturnType<typeof useTranslation<'settingsSections'>>['t']
@@ -82,9 +99,58 @@ export function SshHostsSettingsSection({
const [remoteEnvironments, setRemoteEnvironments] = useState<
Record<string, RemoteEnvironmentLoadState>
>({})
const [remoteEnvironmentUpdate, setRemoteEnvironmentUpdate] =
useState<RemoteEnvironmentUpdateState>()
const [
remoteEnvironmentUpdateErrors,
setRemoteEnvironmentUpdateErrors
] = useState<Record<string, string>>({})
const remoteEnvironmentRequests = useRef(new Map<string, number>())
const activeRemoteEnvironmentUpdate =
useRef<ActiveRemoteEnvironmentUpdate | undefined>(undefined)
const remoteEnvironmentUpdateRequestId = useRef(0)
const mounted = useRef(true)
const api = window.goodbuddy.sshHosts
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
}
}, [])
useEffect(() => {
if (
typeof api?.onRemoteEnvironmentUpdateProgress !== 'function'
) {
return
}
return api.onRemoteEnvironmentUpdateProgress(
({ hostId, phase }) => {
const activeUpdate = activeRemoteEnvironmentUpdate.current
if (
!mounted.current ||
!activeUpdate ||
activeUpdate.hostId !== hostId
) {
return
}
setRemoteEnvironmentUpdate((current) => {
if (
!current ||
current.requestId !== activeUpdate.requestId
) {
return current
}
return {
...current,
phase
}
})
}
)
}, [api])
const requestSnapshot = useCallback((): Promise<SshHostsSnapshot> => {
if (!api) {
return Promise.reject(
@@ -114,7 +180,7 @@ export function SshHostsSettingsSection({
.catch((reason: unknown) => {
if (active) {
setError(
sshHostErrorMessage(
displayErrorMessage(
reason,
t('sshHosts.errors.readFailed')
)
@@ -133,6 +199,9 @@ export function SshHostsSettingsSection({
const requestRemoteEnvironment = useCallback(
async (hostId: string): Promise<void> => {
if (!mounted.current) {
return
}
const requestId =
(remoteEnvironmentRequests.current.get(hostId) ?? 0) + 1
remoteEnvironmentRequests.current.set(hostId, requestId)
@@ -150,6 +219,7 @@ export function SshHostsSettingsSection({
}
const value = await api.getRemoteEnvironment(hostId)
if (
!mounted.current ||
remoteEnvironmentRequests.current.get(hostId) !== requestId
) {
return
@@ -160,6 +230,7 @@ export function SshHostsSettingsSection({
}))
} catch (reason) {
if (
!mounted.current ||
remoteEnvironmentRequests.current.get(hostId) !== requestId
) {
return
@@ -169,7 +240,7 @@ export function SshHostsSettingsSection({
[hostId]: {
loading: false,
value: current[hostId]?.value,
error: sshHostErrorMessage(
error: displayErrorMessage(
reason,
t('sshHosts.errors.environmentReadFailed')
)
@@ -180,6 +251,165 @@ export function SshHostsSettingsSection({
[api, t]
)
const updateRemoteEnvironment = async (
host: SshHost
): Promise<void> => {
if (
typeof api?.updateRemoteEnvironment !== 'function' ||
activeRemoteEnvironmentUpdate.current
) {
return
}
const requestId = remoteEnvironmentUpdateRequestId.current + 1
remoteEnvironmentUpdateRequestId.current = requestId
const activeUpdate: ActiveRemoteEnvironmentUpdate = {
hostId: host.id,
requestId,
cancelRequested: false
}
activeRemoteEnvironmentUpdate.current = activeUpdate
setRemoteEnvironmentUpdateErrors((current) => {
if (!(host.id in current)) {
return current
}
const next = { ...current }
delete next[host.id]
return next
})
setRemoteEnvironmentUpdate({
hostId: host.id,
requestId,
cancelling: false
})
let succeeded = false
try {
await api.updateRemoteEnvironment(host.id)
succeeded = true
} catch (reason) {
const cancelled =
activeUpdate.cancelRequested &&
isCancellationError(reason)
const message = cancelled
? activeUpdate.cancelError ??
t('sshHosts.environment.errors.cancelled')
: displayErrorMessage(
reason,
t('sshHosts.environment.errors.updateFailed')
)
if (mounted.current) {
setRemoteEnvironmentUpdateErrors((current) => ({
...current,
[host.id]: message
}))
} else if (!cancelled) {
onNotify?.({
dedupeKey:
`ssh-host-environment-update-failed:${host.id}`,
message,
tone: 'error'
})
}
} finally {
const currentUpdate = activeRemoteEnvironmentUpdate.current
if (currentUpdate === activeUpdate) {
activeRemoteEnvironmentUpdate.current = undefined
if (succeeded) {
notify(
`ssh-host-environment-updated:${host.id}`,
t('sshHosts.notifications.environmentUpdated', {
name: host.name
})
)
onHostUpdated?.(host.id)
}
if (mounted.current) {
if (succeeded) {
setRemoteEnvironmentUpdateErrors((current) => {
if (!(host.id in current)) {
return current
}
const next = { ...current }
delete next[host.id]
return next
})
}
await requestRemoteEnvironment(host.id)
if (mounted.current) {
setRemoteEnvironmentUpdate((current) =>
current?.requestId === requestId
? undefined
: current
)
}
}
}
}
}
const cancelRemoteEnvironmentUpdate = async (
hostId: string
): Promise<void> => {
const activeUpdate = activeRemoteEnvironmentUpdate.current
if (
!activeUpdate ||
activeUpdate.hostId !== hostId ||
activeUpdate.cancelRequested ||
typeof api?.cancelRemoteEnvironmentUpdate !== 'function'
) {
return
}
activeUpdate.cancelRequested = true
activeUpdate.cancelError = undefined
setRemoteEnvironmentUpdateErrors((current) => {
if (!(hostId in current)) {
return current
}
const next = { ...current }
delete next[hostId]
return next
})
setRemoteEnvironmentUpdate((current) => {
if (
!current ||
current.requestId !== activeUpdate.requestId
) {
return current
}
return {
...current,
cancelling: true
}
})
try {
await api.cancelRemoteEnvironmentUpdate(hostId)
} catch (reason) {
activeUpdate.cancelError = displayErrorMessage(
reason,
t('sshHosts.environment.errors.cancelFailed')
)
if (mounted.current) {
activeUpdate.cancelRequested = false
setRemoteEnvironmentUpdate((current) => {
if (
!current ||
current.requestId !== activeUpdate.requestId
) {
return current
}
return {
...current,
cancelling: false
}
})
setRemoteEnvironmentUpdateErrors((current) => ({
...current,
[hostId]: activeUpdate.cancelError!
}))
}
}
}
useEffect(
() => () => {
for (const [hostId, requestId] of
@@ -279,6 +509,14 @@ export function SshHostsSettingsSection({
delete next[host.id]
return next
})
setRemoteEnvironmentUpdateErrors((current) => {
if (!(host.id in current)) {
return current
}
const next = { ...current }
delete next[host.id]
return next
})
remoteEnvironmentRequests.current.delete(host.id)
notify(
`ssh-host-removed:${host.id}`,
@@ -287,7 +525,7 @@ export function SshHostsSettingsSection({
)
} catch (reason) {
setError(
sshHostErrorMessage(
displayErrorMessage(
reason,
t('sshHosts.errors.removeFailed')
)
@@ -301,6 +539,8 @@ export function SshHostsSettingsSection({
editingId && editingId !== 'new'
? hosts.find((host) => host.id === editingId)
: undefined
const hasActiveRemoteEnvironmentUpdate =
remoteEnvironmentUpdate !== undefined
return (
<div className="ssh-hosts-settings">
@@ -361,7 +601,30 @@ export function SshHostsSettingsSection({
{hosts.map((host) => {
const result = testResults[host.id]
const remoteEnvironment = remoteEnvironments[host.id]
const hostEnvironmentUpdate =
remoteEnvironmentUpdate?.hostId === host.id
? remoteEnvironmentUpdate
: undefined
const remoteEnvironmentUpdateError =
remoteEnvironmentUpdateErrors[host.id]
const deleting = busyHostId === host.id
const environmentNeedsUpdate = Boolean(
remoteEnvironment?.value &&
[
remoteEnvironment.value.agent,
...remoteEnvironment.value.runtimes
].some(
({ state }) =>
state === 'update-available' ||
state === 'not-installed'
)
)
const updateApiAvailable =
typeof api?.updateRemoteEnvironment === 'function' &&
typeof api.cancelRemoteEnvironmentUpdate ===
'function' &&
typeof api.onRemoteEnvironmentUpdateProgress ===
'function'
return (
<section
aria-label={host.name}
@@ -384,7 +647,11 @@ export function SshHostsSettingsSection({
{ name: host.name }
)}
className="secondary-button"
disabled={Boolean(busyHostId) || Boolean(editingId)}
disabled={
Boolean(busyHostId) ||
Boolean(editingId) ||
Boolean(hostEnvironmentUpdate)
}
onClick={() => {
setEditingId(host.id)
setError(undefined)
@@ -452,7 +719,7 @@ export function SshHostsSettingsSection({
{host.lastValidatedAt && (
<div className="ssh-host-environment">
<div className="ssh-host-environment__header">
<div>
<div className="ssh-host-environment__summary">
<span>
<Boxes aria-hidden="true" size={16} />
<strong>
@@ -463,31 +730,118 @@ export function SshHostsSettingsSection({
{t('sshHosts.environment.description')}
</small>
</div>
<button
aria-label={t(
'sshHosts.environment.refreshNamed',
{ name: host.name }
)}
className="secondary-button"
disabled={remoteEnvironment?.loading}
onClick={() =>
void requestRemoteEnvironment(host.id)
}
type="button"
>
{remoteEnvironment?.loading ? (
<LoaderCircle
aria-hidden="true"
className="ssh-host-environment__spinner"
size={13}
/>
) : (
<RefreshCw aria-hidden="true" size={13} />
)}
{t('sshHosts.environment.refresh')}
</button>
<div className="ssh-host-environment__actions">
<button
aria-label={t(
hostEnvironmentUpdate
? 'sshHosts.environment.cancelUpdateNamed'
: 'sshHosts.environment.updateNamed',
{ name: host.name }
)}
className="secondary-button"
disabled={
hostEnvironmentUpdate
? hostEnvironmentUpdate.cancelling ||
hostEnvironmentUpdate.phase ===
'finalizing'
: !host.lastValidatedAt ||
!remoteEnvironment?.value ||
!(
environmentNeedsUpdate ||
remoteEnvironmentUpdateError
) ||
!updateApiAvailable ||
Boolean(remoteEnvironment?.loading) ||
Boolean(busyHostId) ||
Boolean(editingId) ||
hasActiveRemoteEnvironmentUpdate
}
onClick={() => {
if (hostEnvironmentUpdate) {
void cancelRemoteEnvironmentUpdate(
host.id
)
} else {
void updateRemoteEnvironment(host)
}
}}
type="button"
>
{hostEnvironmentUpdate?.cancelling ? (
<LoaderCircle
aria-hidden="true"
className="ssh-host-environment__spinner"
size={13}
/>
) : hostEnvironmentUpdate ? (
<X aria-hidden="true" size={13} />
) : (
<Download aria-hidden="true" size={13} />
)}
{hostEnvironmentUpdate?.cancelling
? t('sshHosts.environment.cancelling')
: hostEnvironmentUpdate
? t('sshHosts.environment.cancelUpdate')
: t('sshHosts.environment.update')}
</button>
<button
aria-label={t(
'sshHosts.environment.refreshNamed',
{ name: host.name }
)}
className="secondary-button"
disabled={
remoteEnvironment?.loading ||
Boolean(hostEnvironmentUpdate)
}
onClick={() =>
void requestRemoteEnvironment(host.id)
}
type="button"
>
{remoteEnvironment?.loading ? (
<LoaderCircle
aria-hidden="true"
className="ssh-host-environment__spinner"
size={13}
/>
) : (
<RefreshCw aria-hidden="true" size={13} />
)}
{t('sshHosts.environment.refresh')}
</button>
</div>
</div>
{hostEnvironmentUpdate && (
<div
className="ssh-host-environment__loading"
role="status"
>
<LoaderCircle
aria-hidden="true"
className="ssh-host-environment__spinner"
size={16}
/>
{t(
hostEnvironmentUpdate.cancelling
? 'sshHosts.environment.progress.cancelling'
: hostEnvironmentUpdate.phase
? `sshHosts.environment.progress.${hostEnvironmentUpdate.phase}`
: 'sshHosts.environment.progress.preparing'
)}
</div>
)}
{remoteEnvironmentUpdateError && (
<div
className="ssh-host-environment__error"
role="alert"
>
{remoteEnvironmentUpdateError}
</div>
)}
{remoteEnvironment?.error && (
<div
className="ssh-host-environment__error"
@@ -553,7 +907,9 @@ export function SshHostsSettingsSection({
'sshHosts.actions.confirmRemove'
)}
confirming={confirmingDeleteId === host.id}
disabled={deleting}
disabled={
deleting || Boolean(hostEnvironmentUpdate)
}
message={t('sshHosts.removeMessage', {
name: host.name
})}
@@ -589,6 +945,27 @@ export function SshHostsSettingsSection({
)
}
function isCancellationError(reason: unknown): boolean {
if (
typeof reason !== 'object' ||
reason === null
) {
return false
}
const name =
'name' in reason && typeof reason.name === 'string'
? reason.name
: ''
const message =
'message' in reason && typeof reason.message === 'string'
? reason.message
: ''
return (
name === 'AbortError' ||
/\b(?:cancelled|canceled)\b|取消/iu.test(message)
)
}
function EnvironmentVersionCard({
expected,
icon,
+3 -11
View File
@@ -15,6 +15,7 @@ import {
SettingsCategoryHeader,
SettingsWarningList
} from './SettingsPrimitives'
import { displayErrorMessage } from './error-message'
import { FeedbackDialog } from './FeedbackDialog'
function formatBytes(bytes: number): string {
@@ -29,20 +30,11 @@ function updateErrorMessage(
fallback: string,
networkMessage: string
): string {
if (!(reason instanceof Error)) {
return fallback
}
const message = reason.message
.replace(
/^Error invoking remote method '[^']+':\s*/,
''
)
.replace(/^(?:TypeError|Error):\s*/, '')
.trim()
const message = displayErrorMessage(reason, fallback)
if (/fetch failed/i.test(message)) {
return networkMessage
}
return message || fallback
return message
}
export function UpdateSettingsSection(): React.JSX.Element {
+24
View File
@@ -0,0 +1,24 @@
export function displayErrorMessage(
reason: unknown,
fallback: string
): string {
const rawMessage =
reason instanceof Error
? reason.message
: typeof reason === 'object' &&
reason !== null &&
'message' in reason &&
typeof reason.message === 'string'
? reason.message
: undefined
if (!rawMessage) {
return fallback
}
const message = rawMessage
.replace(
/^Error invoking remote method '[^']+':\s*(?:(?:[A-Za-z][A-Za-z0-9]*)?Error:\s*)?/u,
''
)
.trim()
return message || fallback
}
@@ -71,10 +71,27 @@ export const settingsSections = {
environment: {
title: 'Remote environment',
description:
'Read-only view of versions registered on this Host. Installation and updates happen only when a remote project is opened.',
'Updating this Host may replace the Agent and Runtime and interrupt active remote work. Host configuration and project files are preserved.',
loading: 'Loading Agent and Runtime versions…',
update: 'Update versions',
updateNamed: 'Update remote environment versions for {{name}}',
cancelUpdate: 'Cancel update',
cancelUpdateNamed: 'Cancel the remote environment update for {{name}}',
cancelling: 'Cancelling…',
refresh: 'Refresh versions',
refreshNamed: 'Refresh remote environment versions for {{name}}',
progress: {
preparing: 'Preparing the update…',
agent: 'Updating GoodBuddy Agent…',
runtime: 'Updating Runtime…',
finalizing: 'Finalizing the remote environment update…',
cancelling: 'Cancelling the remote environment update…'
},
errors: {
updateFailed: 'Could not update the remote environment. Try again.',
cancelled: 'The remote environment update was cancelled. You can retry.',
cancelFailed: 'Could not cancel the remote environment update.'
},
installed: 'Installed on Host',
expected: 'Required by GoodBuddy',
notInstalled: 'Not installed',
@@ -171,7 +188,9 @@ export const settingsSections = {
'Could not read remote environment versions'
},
notifications: {
removed: 'SSH host “{{name}}” deleted'
removed: 'SSH host “{{name}}” deleted',
environmentUpdated:
'Remote environment updated for SSH host “{{name}}”'
}
},
speech: {
@@ -466,7 +485,54 @@ export const settingsSections = {
title: 'Remote Projects (Technical Preview)',
description:
'Enables SSH Host management and GoodBuddy-managed remote projects.',
enabled: 'Enable Remote Projects'
enabled: 'Enable Remote Projects',
agentInventory: {
title: 'GoodBuddy Agent packages',
description:
'Agent packages are released independently and include the Agent, pinned Node, and the remote OpenCode Runtime adapted by GoodBuddy. Online actions use the source selected under About & Updates; nothing is downloaded until remote projects are used.',
loading: 'Verifying local Agent packages…',
refresh: 'Refresh local inventory',
import: 'Import offline package',
export: 'Export offline package',
download: 'Download',
update: 'Check for updates',
listLabel: 'Local Agent package inventory',
summary:
'{{available}} of {{total}} Linux architectures are available.',
states: {
verified: 'Downloaded and verified',
'not-downloaded': 'Not downloaded',
invalid: 'Verification failed'
},
fields: {
agentVersion: 'Agent version',
architecture: 'Architecture',
runtimeVersion: 'Remote OpenCode',
protocol: 'Agent protocol'
},
progress: {
catalog: 'Reading the signed release catalog…',
downloading: 'Downloading the Agent package…',
verifying: 'Verifying signature and integrity…',
installing: 'Writing the local cache…'
},
notifications: {
downloaded: 'Linux {{architecture}} Agent package updated',
imported: 'Agent offline package imported and verified',
exported: 'Linux {{architecture}} Agent package exported'
},
errors: {
unavailable:
'Agent package management is not available in this version.',
readFailed:
'Could not load local Agent packages. Try again.',
downloadFailed:
'Agent package download or verification failed',
importFailed:
'Agent offline package import or verification failed',
exportFailed: 'Agent offline package export failed'
}
}
},
shortcut: {
title: 'Global quick access',
@@ -64,10 +64,27 @@ export const settingsSections = {
environment: {
title: '远程运行环境',
description:
'只读检查当前 Host 已登记版本;打开远程项目时才会安装或更新。',
'更新 Host 可能替换 Agent 和 Runtime,并中断正在进行的远程工作;Host 配置和项目文件会保留。',
loading: '正在读取 Agent 和 Runtime 版本…',
update: '更新版本',
updateNamed: '更新 {{name}} 的远程运行环境版本',
cancelUpdate: '取消更新',
cancelUpdateNamed: '取消 {{name}} 的远程运行环境更新',
cancelling: '正在取消…',
refresh: '刷新版本',
refreshNamed: '刷新 {{name}} 的远程运行环境版本',
progress: {
preparing: '正在准备更新…',
agent: '正在更新 GoodBuddy Agent…',
runtime: '正在更新 Runtime…',
finalizing: '正在完成远程运行环境更新…',
cancelling: '正在取消远程运行环境更新…'
},
errors: {
updateFailed: '更新远程运行环境失败,请重试',
cancelled: '远程运行环境更新已取消,可重试',
cancelFailed: '取消远程运行环境更新失败'
},
installed: 'Host 已安装',
expected: 'GoodBuddy 所需',
notInstalled: '尚未安装',
@@ -160,7 +177,8 @@ export const settingsSections = {
environmentReadFailed: '读取远程运行环境版本失败'
},
notifications: {
removed: 'SSH 主机“{{name}}”已删除'
removed: 'SSH 主机“{{name}}”已删除',
environmentUpdated: 'SSH 主机“{{name}}”的远程运行环境已更新'
}
},
speech: {
@@ -438,7 +456,49 @@ export const settingsSections = {
remoteProjects: {
title: '远程项目(技术预览)',
description: '启用 SSH Host 管理和由 GoodBuddy 托管的远程项目。',
enabled: '启用远程项目'
enabled: '启用远程项目',
agentInventory: {
title: 'GoodBuddy Agent 包',
description:
'Agent 包独立发布,包含 Agent、固定 Node 和桌面版本适配的远端 OpenCode Runtime。在线操作使用“关于与更新”中选择的更新源;未使用远程项目时不会下载。',
loading: '正在校验本地 Agent 包…',
refresh: '刷新本地清单',
import: '导入离线包',
export: '导出离线包',
download: '下载',
update: '检查并更新',
listLabel: '本地 Agent 包清单',
summary: '当前 {{available}} / {{total}} 个 Linux 架构可用。',
states: {
verified: '已下载并验证',
'not-downloaded': '未下载',
invalid: '校验失败'
},
fields: {
agentVersion: 'Agent 版本',
architecture: '架构',
runtimeVersion: '远端 OpenCode',
protocol: 'Agent 协议'
},
progress: {
catalog: '正在读取签名发布目录…',
downloading: '正在下载 Agent 包…',
verifying: '正在校验签名和完整性…',
installing: '正在写入本地缓存…'
},
notifications: {
downloaded: 'Linux {{architecture}} Agent 包已更新',
imported: 'Agent 离线包已导入并验证',
exported: 'Linux {{architecture}} Agent 包已导出'
},
errors: {
unavailable: '当前版本未提供 Agent 包管理服务',
readFailed: '读取本地 Agent 包清单失败,请重试',
downloadFailed: 'Agent 包下载或校验失败',
importFailed: 'Agent 离线包导入或校验失败',
exportFailed: 'Agent 离线包导出失败'
}
}
},
shortcut: {
title: '全局快捷唤起',
-8
View File
@@ -1,8 +0,0 @@
export function sshHostErrorMessage(
reason: unknown,
fallback: string
): string {
return reason instanceof Error && reason.message.trim()
? reason.message
: fallback
}
+103 -5
View File
@@ -6591,6 +6591,96 @@ button > svg {
display: none;
}
.bundled-agent-inventory {
display: grid;
gap: var(--space-3);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.bundled-agent-inventory__item {
min-width: 0;
padding: var(--space-3);
border: 1px solid var(--border-default);
border-radius: var(--radius-control);
background: var(--surface-subtle);
}
.bundled-agent-inventory__item-header {
display: flex;
align-items: center;
color: var(--text-primary);
gap: var(--space-2);
}
.bundled-agent-inventory__item-header > svg {
flex: 0 0 auto;
color: var(--accent);
}
.bundled-agent-inventory__badge {
margin-left: auto;
padding: 2px var(--space-2);
border-radius: 999px;
font-size: var(--font-caption);
white-space: nowrap;
}
.bundled-agent-inventory__badge--verified {
background: var(--success-subtle);
color: var(--success);
}
.bundled-agent-inventory__badge--not-downloaded,
.bundled-agent-inventory__badge--invalid {
background: var(--warning-subtle);
color: var(--warning);
}
.bundled-agent-inventory__item dl {
display: grid;
margin: var(--space-3) 0 0;
gap: var(--space-2);
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.bundled-agent-inventory__item dl > div {
min-width: 0;
}
.bundled-agent-inventory__item dt,
.bundled-agent-inventory__item dd {
margin: 0;
}
.bundled-agent-inventory__item dt {
color: var(--text-muted);
font-size: var(--font-caption);
}
.bundled-agent-inventory__item dd {
overflow: hidden;
color: var(--text-primary);
font-size: var(--font-body);
text-overflow: ellipsis;
white-space: nowrap;
}
.bundled-agent-inventory__spinner {
animation: bundled-agent-inventory-spin 800ms linear infinite;
}
@keyframes bundled-agent-inventory-spin {
to {
transform: rotate(360deg);
}
}
@media (max-width: 720px) {
.bundled-agent-inventory {
grid-template-columns: minmax(0, 1fr);
}
}
.model-download-source {
display: grid;
min-width: 0;
@@ -6895,13 +6985,13 @@ button > svg {
gap: var(--space-3);
}
.ssh-host-environment__header > div {
.ssh-host-environment__summary {
display: grid;
min-width: 0;
gap: var(--space-1);
}
.ssh-host-environment__header > div > span {
.ssh-host-environment__summary > span {
display: flex;
align-items: center;
color: var(--text-primary);
@@ -6909,17 +6999,24 @@ button > svg {
gap: var(--space-2);
}
.ssh-host-environment__header > div > span > svg {
.ssh-host-environment__summary > span > svg {
flex: 0 0 auto;
color: var(--accent);
}
.ssh-host-environment__header small {
.ssh-host-environment__summary small {
color: var(--text-muted);
font-size: var(--font-caption);
line-height: 1.5;
}
.ssh-host-environment__actions {
display: flex;
flex: 0 0 auto;
align-items: center;
gap: var(--space-2);
}
.ssh-host-environment__grid {
display: grid;
gap: var(--space-3);
@@ -7367,8 +7464,9 @@ button > svg {
flex-direction: column;
}
.ssh-host-environment__header > .secondary-button {
.ssh-host-environment__actions {
align-self: flex-start;
flex-wrap: wrap;
}
.ssh-host-card__actions > .danger-confirm {