diff --git a/src/main/ipc.test.ts b/src/main/ipc.test.ts
index 5549d73a..e2e36444 100644
--- a/src/main/ipc.test.ts
+++ b/src/main/ipc.test.ts
@@ -1805,6 +1805,7 @@ describe('registerIpcHandlers SSH hosts', () => {
} as const
const agentPackageManager = {
getAllSnapshot: vi.fn(async () => agentPackageInventory),
+ getAllInventory: vi.fn(async () => agentPackageInventory),
forPlatform: vi.fn(() => agentPackageManager),
download: vi.fn(
async (
@@ -1944,7 +1945,16 @@ describe('registerIpcHandlers SSH hosts', () => {
).resolves.toEqual(agentPackageInventory)
expect(
agentPackageManager.getAllSnapshot
- ).toHaveBeenCalledWith({ refresh: true })
+ ).toHaveBeenCalledWith({ refresh: true, localOnly: false })
+ await expect(
+ electronMocks.handlers.get(
+ ipcChannels.sshHostsAgentPackageInventory
+ )?.(event, { localOnly: true })
+ ).resolves.toEqual(agentPackageInventory)
+ expect(agentPackageManager.getAllInventory).toHaveBeenCalledWith({
+ refresh: false,
+ localOnly: true
+ })
await expect(
electronMocks.handlers.get(
ipcChannels.sshHostsAgentPackageDownload
diff --git a/src/main/ipc.ts b/src/main/ipc.ts
index 876fcf30..ad2b306f 100644
--- a/src/main/ipc.ts
+++ b/src/main/ipc.ts
@@ -4855,7 +4855,9 @@ export function registerIpcHandlers(
const request =
agentPackageInventoryRequestSchema.parse(input ?? {})
return agentPackageInventorySchema.parse(
- await agentPackageManager.getAllSnapshot(request)
+ request.localOnly
+ ? await agentPackageManager.getAllInventory(request)
+ : await agentPackageManager.getAllSnapshot(request)
)
}
)
diff --git a/src/main/remote-agent/agent-package-manager.ts b/src/main/remote-agent/agent-package-manager.ts
index 4313210a..9208bd83 100644
--- a/src/main/remote-agent/agent-package-manager.ts
+++ b/src/main/remote-agent/agent-package-manager.ts
@@ -300,6 +300,17 @@ export class AgentPackageManager {
})
}
+ async getAllInventory(options: { refresh?: boolean } = {}): Promise {
+ const linux = await this.forPlatform('linux').getInventory(options)
+ const darwin = this.forPlatform('darwin')
+ darwin.#catalogState = this.forPlatform('linux').#catalogState
+ const mac = await darwin.getInventory(options)
+ return agentPackageInventorySchema.parse({
+ ...linux,
+ entries: [...linux.entries, ...mac.entries]
+ })
+ }
+
#select(catalog: AgentPackageCatalog, architecture: AgentArchitecture, desktopVersion: string) {
return selectLatestCompatibleEntry(catalog, architecture, desktopVersion, this.#platform)
}
diff --git a/src/preload/index.ts b/src/preload/index.ts
index 19c86869..fa95263d 100644
--- a/src/preload/index.ts
+++ b/src/preload/index.ts
@@ -528,10 +528,10 @@ const desktopApi: DesktopApi = {
handler
)
},
- getAgentPackageInventory: (refresh = false) =>
+ getAgentPackageInventory: (refresh = false, localOnly = false) =>
ipcRenderer.invoke(
ipcChannels.sshHostsAgentPackageInventory,
- { refresh }
+ { refresh, localOnly }
) as Promise,
downloadAgentPackage: (architecture: AgentArchitecture, platform: 'linux' | 'darwin' = 'linux') =>
ipcRenderer.invoke(
diff --git a/src/preload/preload-sandbox.test.ts b/src/preload/preload-sandbox.test.ts
index 81825b9a..d9930403 100644
--- a/src/preload/preload-sandbox.test.ts
+++ b/src/preload/preload-sandbox.test.ts
@@ -292,7 +292,7 @@ describe('sandboxed preload', () => {
'ipcChannels.sshHostsAgentConnectionStatus'
)
expect(source).toContain(
- 'getAgentPackageInventory: (refresh = false)'
+ 'getAgentPackageInventory: (refresh = false, localOnly = false)'
)
expect(source).toContain('downloadAgentPackage:')
expect(source).toContain('importAgentPackage:')
diff --git a/src/renderer/src/AgentPackageInventoryPanel.test.tsx b/src/renderer/src/AgentPackageInventoryPanel.test.tsx
index 5cf896e0..3452db4c 100644
--- a/src/renderer/src/AgentPackageInventoryPanel.test.tsx
+++ b/src/renderer/src/AgentPackageInventoryPanel.test.tsx
@@ -42,7 +42,7 @@ const agentPackageInventory: AgentPackageInventory = {
}
]
}
-const getAgentPackageInventory = vi.fn<(refresh?: boolean) => Promise>()
+const getAgentPackageInventory = vi.fn<(refresh?: boolean, localOnly?: boolean) => Promise>()
const downloadAgentPackage = vi.fn<(architecture: string, platform: string) => Promise>()
const importAgentPackage = vi.fn<() => Promise>()
const exportAgentPackage = vi.fn<(architecture: string, platform: string) => Promise>()
@@ -84,11 +84,12 @@ describe('AgentPackageInventoryPanel', () => {
expect(await screen.findByText('当前 1 / 2 个平台与架构目标可用。')).toBeInTheDocument()
expect(screen.getByText('有更新')).toBeInTheDocument()
expect(screen.getByText('未下载')).toBeInTheDocument()
- expect(getAgentPackageInventory).toHaveBeenCalledExactlyOnceWith(false)
+ expect(getAgentPackageInventory).toHaveBeenNthCalledWith(1, false, true)
+ expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, false)
fireEvent.click(screen.getByRole('button', { name: '刷新包清单' }))
- await waitFor(() => expect(getAgentPackageInventory).toHaveBeenCalledTimes(2))
- expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, true)
+ await waitFor(() => expect(getAgentPackageInventory).toHaveBeenCalledTimes(3))
+ expect(getAgentPackageInventory).toHaveBeenNthCalledWith(3, true)
const x64Card = screen.getByText('Linux x64').closest('[role="listitem"]') as HTMLElement
const arm64Card = screen.getByText('Linux arm64').closest('[role="listitem"]') as HTMLElement
@@ -115,6 +116,37 @@ describe('AgentPackageInventoryPanel', () => {
await waitFor(() => expect(onNotify).toHaveBeenCalledWith(expect.objectContaining({ dedupeKey: 'agent-package-imported' })))
})
+ it('shows the local inventory before the online catalog returns', async () => {
+ const localInventory: AgentPackageInventory = {
+ ...agentPackageInventory,
+ catalog: { state: 'not-checked', checkedAt: null, error: null },
+ entries: agentPackageInventory.entries.map((entry) => ({
+ ...entry,
+ latestVersion: null,
+ updateAvailable: false
+ }))
+ }
+ let resolveOnline: ((inventory: AgentPackageInventory) => void) | undefined
+ getAgentPackageInventory
+ .mockResolvedValueOnce(localInventory)
+ .mockReturnValueOnce(
+ new Promise((resolve) => {
+ resolveOnline = resolve
+ })
+ )
+
+ render()
+ expect(await screen.findByText('当前 1 / 2 个平台与架构目标可用。')).toBeInTheDocument()
+ expect(getAgentPackageInventory).toHaveBeenNthCalledWith(1, false, true)
+ expect(getAgentPackageInventory).toHaveBeenNthCalledWith(2, false)
+
+ await act(async () => {
+ resolveOnline?.(agentPackageInventory)
+ await Promise.resolve()
+ })
+ expect(await screen.findByText('有更新')).toBeInTheDocument()
+ })
+
it('shows loading and recovers from a refresh error', async () => {
let resolveInventory: ((inventory: AgentPackageInventory) => void) | undefined
getAgentPackageInventory.mockReturnValueOnce(
@@ -133,15 +165,18 @@ describe('AgentPackageInventoryPanel', () => {
await screen.findByText('inventory unavailable')
fireEvent.click(screen.getByRole('button', { name: '刷新包清单' }))
await waitFor(() => expect(screen.queryByText('inventory unavailable')).not.toBeInTheDocument())
- expect(getAgentPackageInventory).toHaveBeenCalledTimes(3)
+ expect(getAgentPackageInventory).toHaveBeenCalledTimes(4)
})
it('keeps local Agent packages usable when the online catalog is unavailable', async () => {
- getAgentPackageInventory.mockResolvedValueOnce({
+ const unavailableInventory: AgentPackageInventory = {
...agentPackageInventory,
catalog: { state: 'unavailable', checkedAt: '2026-08-24T00:00:00.000Z', error: 'network unavailable' },
entries: agentPackageInventory.entries.map((entry) => ({ ...entry, latestVersion: null, updateAvailable: false }))
- })
+ }
+ getAgentPackageInventory
+ .mockResolvedValueOnce(unavailableInventory)
+ .mockResolvedValueOnce(unavailableInventory)
render()
expect(await screen.findByText(/network unavailable/u)).toBeInTheDocument()
const x64Card = screen.getByText('Linux x64').closest('[role="listitem"]') as HTMLElement
diff --git a/src/renderer/src/AgentPackageInventoryPanel.tsx b/src/renderer/src/AgentPackageInventoryPanel.tsx
index 2b8f4f33..a0771964 100644
--- a/src/renderer/src/AgentPackageInventoryPanel.tsx
+++ b/src/renderer/src/AgentPackageInventoryPanel.tsx
@@ -54,6 +54,9 @@ export function AgentPackageInventoryPanel({
setAgentInventoryLoading(true)
setAgentInventoryError(undefined)
try {
+ if (!refresh) {
+ setAgentInventory(await getInventory(false, true))
+ }
setAgentInventory(await getInventory(refresh))
} catch (reason) {
setAgentInventoryError(
@@ -259,16 +262,17 @@ export function AgentPackageInventoryPanel({
)}
)}
+ {agentInventoryError && (
+
+ {agentInventoryError}
+
+ )}
{agentInventoryLoading && !agentInventory ? (
{t(
'platformFeatures.remoteProjects.agentInventory.loading'
)}
- ) : agentInventoryError ? (
-
- {agentInventoryError}
-
) : agentInventory ? (
{
).toThrow()
})
- it('accepts only a bounded refresh flag for inventory reads', () => {
+ it('accepts only bounded inventory read flags', () => {
expect(agentPackageInventoryRequestSchema.parse({})).toEqual({
- refresh: false
+ refresh: false,
+ localOnly: false
})
expect(() =>
agentPackageInventoryRequestSchema.parse({
diff --git a/src/shared/agent-package-contracts.ts b/src/shared/agent-package-contracts.ts
index a7339efd..e4e0fdef 100644
--- a/src/shared/agent-package-contracts.ts
+++ b/src/shared/agent-package-contracts.ts
@@ -281,7 +281,8 @@ export type AgentPackageInventory = z.infer<
export const agentPackageInventoryRequestSchema = z
.object({
- refresh: z.boolean().default(false)
+ refresh: z.boolean().default(false),
+ localOnly: z.boolean().default(false)
})
.strict()
diff --git a/src/shared/contracts.ts b/src/shared/contracts.ts
index 5aa77545..cc002cbd 100644
--- a/src/shared/contracts.ts
+++ b/src/shared/contracts.ts
@@ -1869,7 +1869,8 @@ export type DesktopApi = {
listener: (status: SshHostAgentConnectionStatus) => void
) => () => void
getAgentPackageInventory: (
- refresh?: boolean
+ refresh?: boolean,
+ localOnly?: boolean
) => Promise
downloadAgentPackage: (
architecture: AgentArchitecture,