From 72dd05cc31124ace2ba6ba178f5ca6fe013fc373 Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Mon, 31 Aug 2026 05:16:37 +0800 Subject: [PATCH] fix(desktop): keep last-known remote roster rows when the union probe fails When host.agents() rejects (e.g. a registered WSL backend's localhost forwarding went down), the roster query's catch path returned a local-only snapshot, so every previously painted remote Bot row vanished from Bot Mode on the next 5s refresh even though the connection stayed registered (#98844). Merge the last-known $lastRoster rows through the same mergeMultiSourceRoster path used for connect-on-demand sources, marking carried remote rows sourceReachable: false so they paint as offline instead of disappearing. sources is intentionally omitted from the fallback payload so the pane keeps its remembered source snapshot. Empty-previous behavior is unchanged (local-only list stands). --- .../plugins/hermes-bots/data.roster.test.tsx | 36 +++++++++++++++++++ apps/desktop/src/plugins/hermes-bots/data.ts | 16 ++++++++- 2 files changed, 51 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/plugins/hermes-bots/data.roster.test.tsx b/apps/desktop/src/plugins/hermes-bots/data.roster.test.tsx index 60f558c9b6..ba9679ed52 100644 --- a/apps/desktop/src/plugins/hermes-bots/data.roster.test.tsx +++ b/apps/desktop/src/plugins/hermes-bots/data.roster.test.tsx @@ -574,6 +574,42 @@ describe('connect-on-demand sources', () => { }) }) +describe('an aggregate roster failure keeps the last-known remote rows', () => { + // Same shape the pane paints: one rich local row + one remote row already + // carried into $lastRoster by a previous successful refresh. + const previouslyPainted = (connectionId: string) => + [ + { last_session: { id: 'this-chat', last_active: 1 }, name: 'default' }, + { + connectionId, + connectionKind: 'ssh', + connectionLabel: 'Spark', + handle: 'bob', + name: 'bob', + remoteSource: true, + sourceScoped: true + } + ] as RosterRow[] + + it('retains previously painted remote rows as unreachable when host.agents() rejects', async () => { + // mergedRoster(local, null) rejects host.agents(): the union probe fails + // (e.g. a registered WSL backend's localhost forwarding went down) while + // the active source's profiles.list still answers (#98844). + $lastRoster.set(previouslyPainted('spark')) + + const rows = await mergedRoster( + { profiles: [{ last_session: { id: 'this-chat', last_active: 1 }, name: 'default' }] }, + null + ) + + expect(rows.find(row => row.name === 'bob' && row.connectionId === 'spark')).toMatchObject({ + remoteSource: true, + sourceReachable: false + }) + expect(rows.filter(row => row.name === 'default')).toHaveLength(1) + }) +}) + describe('a stalled profiles.list cannot pin the spinner forever', () => { it('gives up after bounded retries and surfaces the error', async () => { // `retry: true` keeps React Query in isLoading until the first success, so diff --git a/apps/desktop/src/plugins/hermes-bots/data.ts b/apps/desktop/src/plugins/hermes-bots/data.ts index 188aa756c1..72cb6c6e9a 100644 --- a/apps/desktop/src/plugins/hermes-bots/data.ts +++ b/apps/desktop/src/plugins/hermes-bots/data.ts @@ -693,7 +693,21 @@ export function useRoster() { fetchedAt: issuedAt } } catch { - /* older build or roster failure — single-source list stands */ + /* Aggregate-roster failure, not a registry change: keep the + * previously painted remote rows on the roster as unreachable + * instead of repainting Bot Mode local-only (#98844). `sources` + * stays absent so the pane falls back to its remembered source + * snapshot rather than clearing it. */ + const previous: RosterRow[] = $lastRoster.get().filter(row => !row?.ghost) + const merged = mergeMultiSourceRoster(local, null, activeConnectionId, previous) + + return { + ...merged, + profiles: (merged?.profiles || []).map(row => + row?.remoteSource ? { ...row, sourceReachable: false } : row + ), + fetchedAt: issuedAt + } } }