fix(desktop): persist pins, reconnect after sleep, dedupe session search
Four related desktop session-management bugs: - Pins lost until refresh: pinned sessions are joined against the paginated in-memory session list, so a pinned chat that aged off the most-recent page got evicted on the next refresh (every message.complete triggers one) and the Pinned section went empty. mergeWorkingSessions -> mergeSessionPage now also preserves pinned rows (matched by live id or lineage root). Pin id checks in the chat header, command center, and delete/archive are normalized to the durable sessionPinId so pins survive auto-compression. - Stuck on "Starting Hermes" after sleep: macOS sleep drops the renderer WebSocket; nothing reconnected on wake so the composer stayed disabled. The gateway boot hook now auto-reconnects with backoff on close/error and on wake signals (powerMonitor resume/unlock-screen IPC, window online, visibilitychange). connect() gains an open timeout so a hung reconnect can't deadlock in 'connecting'. Composer placeholder distinguishes "Reconnecting to Hermes" from a cold start. - Loses chats from itself: the same hard-replace that dropped pins also dropped loaded sessions; mergeSessionPage keeps them. - Multiple copies/branches in search: /api/sessions/search deduped only by raw session_id, so compression segments and branches surfaced as separate hits. It now dedupes by lineage root and returns the live compression tip, matching the session_search tool's behavior.
This commit is contained in:
@@ -49,6 +49,7 @@ type PendingCall = {
|
||||
export interface GatewayClientOptions {
|
||||
closedErrorMessage?: string
|
||||
connectErrorMessage?: string
|
||||
connectTimeoutMs?: number
|
||||
createRequestId?: (nextId: number) => GatewayRequestId
|
||||
requestIdPrefix?: string
|
||||
requestTimeoutMs?: number
|
||||
@@ -58,6 +59,10 @@ export interface GatewayClientOptions {
|
||||
|
||||
const ANY = '*'
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 120_000
|
||||
// A reconnect after sleep/wake must not hang forever in 'connecting' (which
|
||||
// keeps the composer disabled and stuck on "Starting Hermes..."). If the open
|
||||
// handshake doesn't land in this window, fail to 'error' so callers can retry.
|
||||
const DEFAULT_CONNECT_TIMEOUT_MS = 15_000
|
||||
|
||||
export class JsonRpcGatewayClient {
|
||||
private nextId = 0
|
||||
@@ -73,6 +78,7 @@ export class JsonRpcGatewayClient {
|
||||
this.options = {
|
||||
closedErrorMessage: options.closedErrorMessage ?? 'WebSocket closed',
|
||||
connectErrorMessage: options.connectErrorMessage ?? 'WebSocket connection failed',
|
||||
connectTimeoutMs: options.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS,
|
||||
createRequestId:
|
||||
options.createRequestId ?? ((nextId: number) => `${options.requestIdPrefix ?? 'r'}${nextId}`),
|
||||
notConnectedErrorMessage: options.notConnectedErrorMessage ?? 'gateway not connected',
|
||||
@@ -106,20 +112,64 @@ export class JsonRpcGatewayClient {
|
||||
})
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const onOpen = () => {
|
||||
let settled = false
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const cleanup = () => {
|
||||
if (timer !== undefined) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
socket.removeEventListener('open', onOpen)
|
||||
socket.removeEventListener('error', onError)
|
||||
}
|
||||
|
||||
const onOpen = () => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
cleanup()
|
||||
this.setState('open')
|
||||
resolve()
|
||||
}
|
||||
|
||||
const onError = () => {
|
||||
socket.removeEventListener('open', onOpen)
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
cleanup()
|
||||
this.setState('error')
|
||||
reject(new Error(this.options.connectErrorMessage))
|
||||
}
|
||||
|
||||
socket.addEventListener('open', onOpen, { once: true })
|
||||
socket.addEventListener('error', onError, { once: true })
|
||||
|
||||
if (this.options.connectTimeoutMs > 0) {
|
||||
timer = setTimeout(() => {
|
||||
if (settled) {
|
||||
return
|
||||
}
|
||||
|
||||
settled = true
|
||||
cleanup()
|
||||
// Drop the half-open socket so the next connect() starts clean
|
||||
// instead of short-circuiting on a zombie 'connecting' state.
|
||||
try {
|
||||
this.socket?.close()
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
this.socket = null
|
||||
this.setState('error')
|
||||
reject(new Error(this.options.connectErrorMessage))
|
||||
}, this.options.connectTimeoutMs)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user