Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/desktop/src/app/desktop-controller.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -885,6 +885,7 @@ export function DesktopController() {
onGatewayReady: g => {
gatewayRef.current = g
},
refreshActiveSession: () => hydrateFromStoredSession(3),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

hydrateFromStoredSession() awaits REST and then replaces messages (desktop-controller.tsx:508-515), but the WebSocket listener is already live after reconnect. A delta received while that request is pending can be overwritten by the older snapshot. Please make the hydrate response generation-aware or otherwise preserve newer live updates.

refreshHermesConfig,
refreshSessions
})
Expand Down
24 changes: 23 additions & 1 deletion apps/desktop/src/app/gateway/hooks/use-gateway-boot.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -105,11 +105,12 @@ function fakeDesktop() {
}
}

function Harness() {
function Harness({ refreshActiveSession }: { refreshActiveSession?: () => Promise<void> } = {}) {
useGatewayBoot({
handleGatewayEvent: () => undefined,
onConnectionReady: () => undefined,
onGatewayReady: () => undefined,
refreshActiveSession,
refreshHermesConfig: async () => undefined,
refreshSessions: async () => undefined
})
Expand Down Expand Up @@ -267,4 +268,25 @@ describe('useGatewayBoot remote reconnect loop (real hook, fake socket)', () =>
expect($gatewayState.get()).toBe('open')
expect($desktopBoot.get().error).toBeNull()
})

it('resyncs the active transcript after reconnect so missed gateway events do not require an app restart', async () => {
const refreshActiveSession = vi.fn(async () => undefined)

render(<Harness refreshActiveSession={refreshActiveSession} />)
await flushAsync()
expect(refreshActiveSession).not.toHaveBeenCalled()

FakeWebSocket.mode = 'fail'
act(() => FakeWebSocket.instances[0].drop())
await flushAsync()
await advanceBackoff()

expect(refreshActiveSession).not.toHaveBeenCalled()

FakeWebSocket.mode = 'open'
await advanceBackoff()

expect($gatewayState.get()).toBe('open')
expect(refreshActiveSession).toHaveBeenCalledTimes(1)
})
})
10 changes: 10 additions & 0 deletions apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,9 @@ interface GatewayBootOptions {
connection: Awaited<ReturnType<NonNullable<typeof window.hermesDesktop>['getConnection']>> | null
) => void
onGatewayReady: (gateway: HermesGateway | null) => void
/** Rehydrate the active transcript after a successful reconnect so deltas
* emitted while the WebSocket was down don't require an app restart. */
refreshActiveSession?: () => Promise<void>
refreshHermesConfig: () => Promise<void>
refreshSessions: () => Promise<void>
}
Expand All @@ -62,13 +65,15 @@ export function useGatewayBoot({
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
refreshActiveSession,
refreshHermesConfig,
refreshSessions
}: GatewayBootOptions) {
const callbacksRef = useRef({
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
refreshActiveSession,
refreshHermesConfig,
refreshSessions
})
Expand All @@ -77,6 +82,7 @@ export function useGatewayBoot({
handleGatewayEvent,
onConnectionReady,
onGatewayReady,
refreshActiveSession,
refreshHermesConfig,
refreshSessions
}
Expand Down Expand Up @@ -168,8 +174,12 @@ export function useGatewayBoot({

reconnectAttempt = 0
// Resync state that may have moved on the backend while we were asleep.
// In addition to the sidebar/config, rehydrate the active transcript:
// any deltas/completion events emitted while the WebSocket was down are
// otherwise missing from the renderer until a full app restart.
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
await callbacksRef.current.refreshSessions().catch(() => undefined)
await callbacksRef.current.refreshActiveSession?.().catch(() => undefined)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hook is not the only primary reconnect path: useGatewayRequest calls existing.connect() directly after a failed RPC (apps/desktop/src/app/gateway/hooks/use-gateway-request.ts:54-78). That successful open clears this hook's scheduled timer, so this callback never runs for that reconnect. Please move the reconciliation to a shared primary-reconnect transition or cover the request-triggered path too.

} catch (err) {
// OAuth session expired mid-reconnect: surface the actionable "sign in
// again" message once instead of silently looping the backoff against a
Expand Down
Loading