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).
This commit is contained in:
liuhao1024
2026-08-31 05:16:37 +08:00
committed by Teknium
parent 04ded3b145
commit 72dd05cc31
2 changed files with 51 additions and 1 deletions

View File

@@ -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

View File

@@ -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
}
}
}