diff --git a/apps/desktop/src/app/contrib/active-transcript-refresh.test.ts b/apps/desktop/src/app/contrib/active-transcript-refresh.test.ts new file mode 100644 index 0000000000000..5cd6298584cbd --- /dev/null +++ b/apps/desktop/src/app/contrib/active-transcript-refresh.test.ts @@ -0,0 +1,130 @@ +import type { MutableRefObject } from 'react' +import { describe, expect, it, vi } from 'vitest' + +import type { SessionMessage, SessionMessagesResponse } from '@/hermes' +import type { SessionInfo } from '@/types/hermes' + +import type { ClientSessionState } from '../types' + +import { advanceTranscriptScope, refreshActiveTranscript } from './active-transcript-refresh' + +const ref = (current: T): MutableRefObject => ({ current }) + +const idleState = (storedSessionId = 'stored-a'): ClientSessionState => + ({ + storedSessionId, + messages: [], + busy: false, + awaitingResponse: false, + streamId: null + }) as unknown as ClientSessionState + +const message = (content: string): SessionMessage => ({ content, role: 'user', timestamp: 1 }) +const response = (messages: SessionMessage[]): SessionMessagesResponse => ({ messages, session_id: 'stored-a' }) +const stored = (id = 'stored-a'): SessionInfo => ({ id, profile: 'san' }) as SessionInfo + +function harness() { + const activeSessionIdRef = ref('runtime-a') + const busyRef = ref(false) + const generationRef = ref(0) + const selectedStoredSessionIdRef = ref('stored-a') + const sessionStateByRuntimeIdRef = ref(new Map([['runtime-a', idleState()]])) + const signatureRef = ref(new Map()) + const findStoredSession = vi.fn(() => stored()) + + const updateSessionState = vi.fn( + (runtimeSessionId: string, updater: (state: ClientSessionState) => ClientSessionState) => { + const previous = sessionStateByRuntimeIdRef.current.get(runtimeSessionId)! + const next = updater(previous) + sessionStateByRuntimeIdRef.current.set(runtimeSessionId, next) + + return next + } + ) + + return { + activeSessionIdRef, + busyRef, + findStoredSession, + generationRef, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + signatureRef, + updateSessionState + } +} + +describe('active transcript refresh', () => { + it('publishes a changed persisted transcript into its idle owning runtime', async () => { + const h = harness() + + await refreshActiveTranscript({ + ...h, + loadMessages: vi.fn().mockResolvedValue(response([message('remote')])) + }) + + expect(h.updateSessionState).toHaveBeenCalledTimes(1) + expect(h.sessionStateByRuntimeIdRef.current.get('runtime-a')?.messages).toHaveLength(1) + expect(h.signatureRef.current.size).toBe(1) + }) + + it('rejects an old A response after an A to B to A scope transition', async () => { + const h = harness() + const scopeKeyRef = ref('remote-a:san:stored-a:runtime-a') + let resolve: ((messages: SessionMessagesResponse) => void) | undefined + + const loadMessages = vi.fn( + () => + new Promise(res => { + resolve = res + }) + ) + + const pending = refreshActiveTranscript({ ...h, loadMessages }) + + advanceTranscriptScope( + { generationRef: h.generationRef, scopeKeyRef }, + 'remote-a:san:stored-b:runtime-b' + ) + advanceTranscriptScope( + { generationRef: h.generationRef, scopeKeyRef }, + 'remote-a:san:stored-a:runtime-a' + ) + resolve?.(response([message('stale')])) + await pending + + expect(h.updateSessionState).not.toHaveBeenCalled() + expect(h.signatureRef.current.size).toBe(0) + }) + + it('rejects a response when a local turn starts while the request is pending', async () => { + const h = harness() + let resolve: ((messages: SessionMessagesResponse) => void) | undefined + + const loadMessages = vi.fn( + () => + new Promise(res => { + resolve = res + }) + ) + + const pending = refreshActiveTranscript({ ...h, loadMessages }) + h.busyRef.current = true + h.sessionStateByRuntimeIdRef.current.set('runtime-a', { ...idleState(), busy: true }) + resolve?.(response([message('stale')])) + await pending + + expect(h.updateSessionState).not.toHaveBeenCalled() + }) + + it('keeps the runtime untouched when the persisted signature did not change', async () => { + const h = harness() + const latest = [message('same')] + + await refreshActiveTranscript({ ...h, loadMessages: vi.fn().mockResolvedValue(response(latest)) }) + h.updateSessionState.mockClear() + await refreshActiveTranscript({ ...h, loadMessages: vi.fn().mockResolvedValue(response(latest)) }) + + expect(h.updateSessionState).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/src/app/contrib/active-transcript-refresh.ts b/apps/desktop/src/app/contrib/active-transcript-refresh.ts new file mode 100644 index 0000000000000..dfaba47655964 --- /dev/null +++ b/apps/desktop/src/app/contrib/active-transcript-refresh.ts @@ -0,0 +1,142 @@ +import type { MutableRefObject } from 'react' + +import { getSessionMessages } from '@/hermes' +import { preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' +import { sessionMessagesSignature } from '@/lib/session-signatures' +import type { SessionInfo } from '@/types/hermes' + +import type { ClientSessionState } from '../types' + +interface ActiveTranscriptRefreshRefs { + activeSessionIdRef: MutableRefObject + busyRef: MutableRefObject + generationRef: MutableRefObject + selectedStoredSessionIdRef: MutableRefObject + sessionStateByRuntimeIdRef: MutableRefObject> + signatureRef: MutableRefObject> +} + +interface RefreshActiveTranscriptOptions extends ActiveTranscriptRefreshRefs { + findStoredSession: (storedSessionId: string) => SessionInfo | undefined + loadMessages?: typeof getSessionMessages + updateSessionState: ( + runtimeSessionId: string, + updater: (state: ClientSessionState) => ClientSessionState, + storedSessionId?: null | string + ) => ClientSessionState +} + +interface TranscriptScopeRefs { + generationRef: MutableRefObject + scopeKeyRef: MutableRefObject +} + +export function advanceTranscriptScope({ generationRef, scopeKeyRef }: TranscriptScopeRefs, scopeKey: string): void { + if (scopeKeyRef.current === scopeKey) { + return + } + + scopeKeyRef.current = scopeKey + generationRef.current += 1 +} + +function isIdleOwnedState( + state: ClientSessionState | undefined, + storedSessionId: string +): state is ClientSessionState { + return Boolean( + state && + state.storedSessionId === storedSessionId && + !state.busy && + !state.awaitingResponse && + state.streamId == null + ) +} + +export async function refreshActiveTranscript({ + activeSessionIdRef, + busyRef, + findStoredSession, + generationRef, + loadMessages = getSessionMessages, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + signatureRef, + updateSessionState +}: RefreshActiveTranscriptOptions): Promise { + const storedSessionId = selectedStoredSessionIdRef.current + const runtimeSessionId = activeSessionIdRef.current + const generation = generationRef.current + + if (!storedSessionId || !runtimeSessionId || busyRef.current) { + return + } + + const initialState = sessionStateByRuntimeIdRef.current.get(runtimeSessionId) + + if (!isIdleOwnedState(initialState, storedSessionId)) { + return + } + + const stored = findStoredSession(storedSessionId) + + if (!stored) { + return + } + + try { + const latest = await loadMessages(storedSessionId, stored.profile) + + if ( + generationRef.current !== generation || + selectedStoredSessionIdRef.current !== storedSessionId || + activeSessionIdRef.current !== runtimeSessionId || + busyRef.current + ) { + return + } + + const currentState = sessionStateByRuntimeIdRef.current.get(runtimeSessionId) + + if (!isIdleOwnedState(currentState, storedSessionId)) { + return + } + + const signatureKey = `${stored.profile ?? 'default'}:${storedSessionId}` + const signature = sessionMessagesSignature(latest.messages) + + if (signatureRef.current.get(signatureKey) === signature) { + return + } + + const remoteMessages = toChatMessages(latest.messages) + const messages = preserveLocalAssistantErrors(remoteMessages, currentState.messages) + let applied = false + + updateSessionState( + runtimeSessionId, + state => { + if ( + generationRef.current !== generation || + selectedStoredSessionIdRef.current !== storedSessionId || + activeSessionIdRef.current !== runtimeSessionId || + busyRef.current || + !isIdleOwnedState(state, storedSessionId) + ) { + return state + } + + applied = true + + return { ...state, messages } + }, + storedSessionId + ) + + if (applied) { + signatureRef.current.set(signatureKey, signature) + } + } catch { + // Non-fatal: the next poll or a manual resume can hydrate the transcript. + } +} diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts index 22a46927af0e4..8328f6016015f 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.test.ts @@ -1,5 +1,8 @@ +// @vitest-environment jsdom +import { act, cleanup, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { $connection } from '@/store/session' import { $attentionSessionIds, $stalledSessionIds, @@ -8,7 +11,163 @@ import { SESSION_WATCHDOG_TIMEOUT_MS } from '@/store/session-states' -import { rehydrateLiveSessionStatuses } from './use-background-sync' +import { rehydrateLiveSessionStatuses, useBackgroundSync } from './use-background-sync' + +describe('useBackgroundSync', () => { + beforeEach(() => { + vi.useFakeTimers() + Object.defineProperty(document, 'visibilityState', { configurable: true, value: 'visible' }) + $connection.set({ baseUrl: 'http://shared-gateway', mode: 'remote', profile: 'san' } as never) + }) + + afterEach(() => { + cleanup() + $connection.set(null) + vi.clearAllTimers() + vi.useRealTimers() + }) + + it('refreshes local sessions while a shared gateway is open', () => { + const refreshSessions = vi.fn().mockResolvedValue(undefined) + + renderHook(() => + useBackgroundSync({ + activeGatewayProfile: 'san', + activeIsMessaging: false, + activeSessionId: null, + freshDraftReady: false, + gatewayState: 'open', + refreshActiveTranscript: vi.fn().mockResolvedValue(undefined), + refreshCronJobs: vi.fn().mockResolvedValue(undefined), + refreshCurrentModel: vi.fn().mockResolvedValue(undefined), + refreshHermesConfig: vi.fn().mockResolvedValue(undefined), + refreshMessagingSessions: vi.fn().mockResolvedValue(undefined), + refreshSessions, + requestGateway: vi.fn().mockResolvedValue({ sessions: [] }) + }) + ) + + expect(refreshSessions).toHaveBeenCalledTimes(1) + + act(() => { + vi.advanceTimersByTime(2_000) + }) + + expect(refreshSessions).toHaveBeenCalledTimes(2) + const pollOptions = refreshSessions.mock.calls[1][0] + + expect(pollOptions?.refreshCronJobs).toBe(false) + expect(pollOptions?.skipIfBusy).toBe(true) + expect(pollOptions?.shouldApply?.()).toBe(true) + + act(() => { + $connection.set({ baseUrl: 'http://different-gateway', mode: 'remote', profile: 'work' } as never) + }) + + expect(pollOptions?.shouldApply?.()).toBe(false) + }) + + it('refreshes the open local transcript while a shared gateway is visible', () => { + const refreshActiveTranscript = vi.fn().mockResolvedValue(undefined) + + renderHook(() => + useBackgroundSync({ + activeGatewayProfile: 'san', + activeIsMessaging: false, + activeSessionId: 'runtime-local', + freshDraftReady: false, + gatewayState: 'open', + refreshActiveTranscript, + refreshCronJobs: vi.fn().mockResolvedValue(undefined), + refreshCurrentModel: vi.fn().mockResolvedValue(undefined), + refreshHermesConfig: vi.fn().mockResolvedValue(undefined), + refreshMessagingSessions: vi.fn().mockResolvedValue(undefined), + refreshSessions: vi.fn().mockResolvedValue(undefined), + requestGateway: vi.fn().mockResolvedValue({ sessions: [] }) + }) + ) + + expect(refreshActiveTranscript).not.toHaveBeenCalled() + + act(() => { + vi.advanceTimersByTime(2_000) + }) + + expect(refreshActiveTranscript).toHaveBeenCalledTimes(1) + }) + + it('coalesces slow open-transcript refreshes', async () => { + let release: (() => void) | undefined + + const refreshActiveTranscript = vi.fn( + () => + new Promise(resolve => { + release = resolve + }) + ) + + renderHook(() => + useBackgroundSync({ + activeGatewayProfile: 'san', + activeIsMessaging: false, + activeSessionId: 'runtime-local', + freshDraftReady: false, + gatewayState: 'open', + refreshActiveTranscript, + refreshCronJobs: vi.fn().mockResolvedValue(undefined), + refreshCurrentModel: vi.fn().mockResolvedValue(undefined), + refreshHermesConfig: vi.fn().mockResolvedValue(undefined), + refreshMessagingSessions: vi.fn().mockResolvedValue(undefined), + refreshSessions: vi.fn().mockResolvedValue(undefined), + requestGateway: vi.fn().mockResolvedValue({ sessions: [] }) + }) + ) + + await act(async () => { + vi.advanceTimersByTime(4_000) + }) + + expect(refreshActiveTranscript).toHaveBeenCalledTimes(1) + + await act(async () => { + release?.() + await Promise.resolve() + vi.advanceTimersByTime(2_000) + }) + + expect(refreshActiveTranscript).toHaveBeenCalledTimes(2) + }) + + it('does not poll local-only sessions', () => { + $connection.set({ mode: 'local' } as never) + const refreshSessions = vi.fn().mockResolvedValue(undefined) + const refreshActiveTranscript = vi.fn().mockResolvedValue(undefined) + + renderHook(() => + useBackgroundSync({ + activeGatewayProfile: 'san', + activeIsMessaging: false, + activeSessionId: 'runtime-local', + freshDraftReady: false, + gatewayState: 'open', + refreshActiveTranscript, + refreshCronJobs: vi.fn().mockResolvedValue(undefined), + refreshCurrentModel: vi.fn().mockResolvedValue(undefined), + refreshHermesConfig: vi.fn().mockResolvedValue(undefined), + refreshMessagingSessions: vi.fn().mockResolvedValue(undefined), + refreshSessions, + requestGateway: vi.fn().mockResolvedValue({ sessions: [] }) + }) + ) + + act(() => { + vi.advanceTimersByTime(2_000) + }) + + expect(refreshSessions).toHaveBeenCalledTimes(1) + expect(refreshActiveTranscript).not.toHaveBeenCalled() + }) +}) describe('rehydrateLiveSessionStatuses', () => { beforeEach(() => { diff --git a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts index 5925b4a7dfbe0..be980767a3994 100644 --- a/apps/desktop/src/app/contrib/hooks/use-background-sync.ts +++ b/apps/desktop/src/app/contrib/hooks/use-background-sync.ts @@ -1,8 +1,9 @@ +import { useStore } from '@nanostores/react' import { useEffect } from 'react' import { createClientSessionState } from '@/lib/chat-runtime' import { refreshActiveProfile } from '@/store/profile' -import { $activeSessionId, $currentCwd, setCurrentCwd } from '@/store/session' +import { $activeSessionId, $connection, $currentCwd, setCurrentCwd } from '@/store/session' import { $sessionStates, publishSessionState, @@ -18,6 +19,11 @@ import type { GatewayRequester } from '../types' const CRON_POLL_INTERVAL_MS = 30_000 const MESSAGING_POLL_INTERVAL_MS = 10_000 const ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS = 5_000 +const ACTIVE_LOCAL_SESSION_POLL_INTERVAL_MS = 2_000 +// Another Desktop connected to the same remote gateway writes sessions without +// emitting an event into this renderer's websocket. Reconcile the backend-owned +// list while visible so cross-device chats appear without a manual reload. +const LOCAL_SESSION_POLL_INTERVAL_MS = 2_000 // Match the TUI's live-session refresh cadence. Auto-compression can rotate a // stored session id while its turn keeps running; until the next snapshot the // sidebar row points at the new id while the renderer still knows the old one. @@ -37,6 +43,10 @@ interface LiveSessionStatusResponse { sessions?: LiveSessionStatusItem[] } +function remoteConnectionKey(connection: { baseUrl?: string; mode?: string; profile?: string } | null): null | string { + return connection?.mode === 'remote' ? `${connection.baseUrl ?? ''}\0${connection.profile ?? ''}` : null +} + /** Restore sidebar liveness after a renderer/backend reconnect. Stream events * normally own these states, but events emitted while Desktop was disconnected * cannot be replayed. `session.active_list` is the authoritative in-memory @@ -95,12 +105,16 @@ interface BackgroundSyncParams { activeSessionId: null | string freshDraftReady: boolean gatewayState: string - refreshActiveMessagingTranscript: () => Promise | unknown + refreshActiveTranscript: () => Promise | unknown refreshCronJobs: () => Promise | unknown refreshCurrentModel: (force?: boolean) => Promise | unknown refreshHermesConfig: () => Promise | unknown refreshMessagingSessions: () => Promise | unknown - refreshSessions: () => Promise | unknown + refreshSessions: (options?: { + refreshCronJobs?: boolean + shouldApply?: () => boolean + skipIfBusy?: boolean + }) => Promise | unknown requestGateway: GatewayRequester } @@ -134,7 +148,7 @@ export function useBackgroundSync({ activeSessionId, freshDraftReady, gatewayState, - refreshActiveMessagingTranscript, + refreshActiveTranscript, refreshCronJobs, refreshCurrentModel, refreshHermesConfig, @@ -142,6 +156,8 @@ export function useBackgroundSync({ refreshSessions, requestGateway }: BackgroundSyncParams): void { + const sharedGatewayKey = remoteConnectionKey(useStore($connection)) + useEffect(() => { if (gatewayState !== 'open') { return @@ -231,8 +247,80 @@ export function useBackgroundSync({ return visiblePoll(MESSAGING_POLL_INTERVAL_MS, () => void refreshMessagingSessions()) }, [gatewayState, refreshMessagingSessions]) - // Only the open messaging transcript needs its own poll — local chats are - // live over the websocket already. + // Keep ordinary Desktop-created chats synchronized across clients sharing a + // remote gateway. Same-window turn completions already refresh immediately; + // this poll covers writes originating from a different websocket/client. + useEffect(() => { + if (gatewayState !== 'open' || !sharedGatewayKey) { + return + } + + let active = true + + const dispose = visiblePoll(LOCAL_SESSION_POLL_INTERVAL_MS, () => { + void Promise.resolve( + refreshSessions({ + refreshCronJobs: false, + shouldApply: () => active && remoteConnectionKey($connection.get()) === sharedGatewayKey, + skipIfBusy: true + }) + ).catch(() => undefined) + }) + + return () => { + active = false + dispose() + } + }, [gatewayState, refreshSessions, sharedGatewayKey]) + + // Another Desktop can append to the currently open ordinary chat without + // sending message events to this renderer. Reconcile that persisted + // transcript while visible; the callback itself skips active local streams. + useEffect(() => { + if (gatewayState !== 'open' || !activeSessionId || activeIsMessaging || !sharedGatewayKey) { + return + } + + let active = true + let inFlight = false + + const runActiveTranscriptRefresh = async () => { + if (inFlight) { + return + } + + inFlight = true + + try { + await refreshActiveTranscript() + } catch { + // Non-fatal: the next visible tick can reconcile again. + } finally { + if (active) { + inFlight = false + } + } + } + + const dispose = visiblePoll( + ACTIVE_LOCAL_SESSION_POLL_INTERVAL_MS, + () => void runActiveTranscriptRefresh() + ) + + return () => { + active = false + dispose() + } + }, [ + activeIsMessaging, + activeSessionId, + gatewayState, + refreshActiveTranscript, + sharedGatewayKey + ]) + + // Messaging transcripts need the same reconciliation on every connection: + // inbound platform turns arrive through the background gateway. useEffect(() => { if (gatewayState !== 'open' || !activeIsMessaging) { return @@ -240,13 +328,13 @@ export function useBackgroundSync({ const dispose = visiblePoll( ACTIVE_MESSAGING_SESSION_POLL_INTERVAL_MS, - () => void refreshActiveMessagingTranscript() + () => void refreshActiveTranscript() ) - void refreshActiveMessagingTranscript() + void refreshActiveTranscript() return dispose - }, [activeIsMessaging, gatewayState, refreshActiveMessagingTranscript]) + }, [activeIsMessaging, gatewayState, refreshActiveTranscript]) // A fresh new-session draft (gateway open, no active session) re-pulls the // model + config so the composer pill reflects the profile default. diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 47beed69bcbca..a1669a743b5ad 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -24,7 +24,6 @@ import { RemoteDisplayBanner } from '@/components/remote-display-banner' import { emitGatewayEvent } from '@/contrib/events' import { getSessionMessages, triggerCronJob } from '@/hermes' import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' -import { sessionMessagesSignature } from '@/lib/session-signatures' import { isMessagingSource } from '@/lib/session-source' import { latestSessionTodos } from '@/lib/todos' import { setCronFocusJobId } from '@/store/cron' @@ -95,6 +94,10 @@ import { titlebarControlsPosition } from '../shell/titlebar' import { TitlebarControls } from '../shell/titlebar-controls' import { UpdatesOverlay } from '../updates-overlay' +import { + advanceTranscriptScope, + refreshActiveTranscript as refreshActivePersistedTranscript +} from './active-transcript-refresh' import { ContribWiringContext } from './context' import { useBackgroundSync } from './hooks/use-background-sync' import { useDesktopIntegrations } from './hooks/use-desktop-integrations' @@ -126,12 +129,16 @@ export function ContribWiring({ children }: { children: ReactNode }) { const busyRef = useRef(false) const creatingSessionRef = useRef(false) - const messagingTranscriptSignatureRef = useRef(new Map()) + const activeTranscriptGenerationRef = useRef(0) + const activeTranscriptScopeKeyRef = useRef('') + const activeTranscriptSignatureRef = useRef(new Map()) // Stable identity for the whole callback surface (see WiringActions). Mutated // in place each render so memoized surfaces never re-render on churn. const actionsRef = useRef(null) const gatewayState = useStore($gatewayState) + const connection = useStore($connection) + const activeGatewayProfile = useStore($activeGatewayProfile) const activeSessionId = useStore($activeSessionId) const currentCwd = useStore($currentCwd) const freshDraftReady = useStore($freshDraftReady) @@ -148,6 +155,21 @@ export function ContribWiring({ children }: { children: ReactNode }) { const routeToken = `${location.pathname}:${location.search}:${location.hash}` const routeTokenRef = useRef(routeToken) routeTokenRef.current = routeToken + + const activeTranscriptScopeKey = [ + connection?.mode ?? 'none', + connection?.baseUrl ?? '', + connection?.profile ?? '', + activeGatewayProfile, + routeToken, + selectedStoredSessionId ?? '', + activeSessionId ?? '' + ].join('\u0000') + + advanceTranscriptScope( + { generationRef: activeTranscriptGenerationRef, scopeKeyRef: activeTranscriptScopeKeyRef }, + activeTranscriptScopeKey + ) const getRouteToken = useCallback(() => routeTokenRef.current, []) const getRoutedStoredSessionId = useCallback(() => routedSessionIdRef.current, []) @@ -301,44 +323,24 @@ export function ContribWiring({ children }: { children: ReactNode }) { [activeSessionIdRef, selectedStoredSessionIdRef, updateSessionState] ) - // Refresh the open messaging transcript (inbound platform turns arrive via - // the background gateway, not the desktop websocket). Signature-gated so a - // no-change poll doesn't churn the thread. - const refreshActiveMessagingTranscript = useCallback(async () => { - const storedSessionId = selectedStoredSessionIdRef.current - const runtimeSessionId = activeSessionIdRef.current - - if (!storedSessionId || !runtimeSessionId || busyRef.current) { - return - } - - const stored = $messagingSessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)) - - if (!stored || !isMessagingSource(stored.source)) { - return - } - - try { - const latest = await getSessionMessages(storedSessionId, stored.profile) - const signatureKey = `${stored.profile ?? 'default'}:${storedSessionId}` - const sig = sessionMessagesSignature(latest.messages) - - if (messagingTranscriptSignatureRef.current.get(signatureKey) === sig) { - return - } - - messagingTranscriptSignatureRef.current.set(signatureKey, sig) - const messages = toChatMessages(latest.messages) - - updateSessionState( - runtimeSessionId, - state => ({ ...state, messages: preserveLocalAssistantErrors(messages, state.messages) }), - storedSessionId - ) - } catch { - // Non-fatal: next poll or manual refresh can hydrate. - } - }, [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState]) + // Refresh the open persisted transcript. Messaging turns and ordinary chats + // written by another Desktop both bypass this renderer's websocket. + const refreshActiveTranscript = useCallback( + () => + refreshActivePersistedTranscript({ + activeSessionIdRef, + busyRef, + findStoredSession: storedSessionId => + $messagingSessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? + $sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)), + generationRef: activeTranscriptGenerationRef, + selectedStoredSessionIdRef, + sessionStateByRuntimeIdRef, + signatureRef: activeTranscriptSignatureRef, + updateSessionState + }), + [activeSessionIdRef, selectedStoredSessionIdRef, sessionStateByRuntimeIdRef, updateSessionState] + ) const { handleGatewayEvent } = useMessageStream({ activeSessionIdRef, @@ -427,7 +429,6 @@ export function ContribWiring({ children }: { children: ReactNode }) { // Swapping the live gateway to another profile must re-pull that profile's // global model + active-profile pill (both are nanostores — the blanket // invalidateQueries on swap doesn't touch them). - const activeGatewayProfile = useStore($activeGatewayProfile) const lastGatewayProfileRef = useRef(activeGatewayProfile) useEffect(() => { @@ -666,7 +667,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { activeSessionId, freshDraftReady, gatewayState, - refreshActiveMessagingTranscript, + refreshActiveTranscript, refreshCronJobs, refreshCurrentModel, refreshHermesConfig, @@ -842,7 +843,6 @@ export function ContribWiring({ children }: { children: ReactNode }) { // (preview's monitor/devtools cluster, …) arrive as registry contributions. const leftTitlebarTools = useTitlebarToolContributions('left') const rightTitlebarTools = useTitlebarToolContributions('right') - const connection = useStore($connection) const controlsPos = titlebarControlsPosition(connection?.windowButtonPosition, Boolean(connection?.isFullscreen)) // Exact vertical centering: titlebarControlsPosition() returns // (TITLEBAR_HEIGHT - TITLEBAR_CONTROL_HEIGHT) / 2, but TitlebarControls diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx index 0147d45e4a8b8..e6b605c5e6d28 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.test.tsx @@ -1,7 +1,7 @@ import { act, renderHook } from '@testing-library/react' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import type { SessionInfo, SidebarSessionsResponse } from '@/hermes' +import { getCronJobs, type SessionInfo, type SidebarSessionsResponse } from '@/hermes' import { $cronSessions, $messagingSessions, @@ -79,6 +79,60 @@ afterEach(() => { }) describe('refreshSessions identity + loading hygiene', () => { + it('can skip the cron-job side effect for frequent session reconciliation', async () => { + vi.mocked(getCronJobs).mockClear() + listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} })) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + + await act(async () => { + await result.current.refreshSessions({ refreshCronJobs: false }) + }) + + expect(getCronJobs).not.toHaveBeenCalled() + }) + + it('discards a response when its caller is no longer current', async () => { + listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('stale')], total: 1, profile_totals: {} })) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + + await act(async () => { + await result.current.refreshSessions({ shouldApply: () => false }) + }) + + expect($sessions.get()).toEqual([]) + }) + + it('skips a polling refresh while another refresh is in flight', async () => { + let resolveRequest!: (value: SidebarSessionsResponse) => void + + const pendingRequest = new Promise(resolve => { + resolveRequest = resolve + }) + + listSidebarSessions.mockReturnValueOnce(pendingRequest) + const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' })) + let firstRefresh!: Promise + + await act(async () => { + firstRefresh = result.current.refreshSessions() + await Promise.resolve() + }) + + await act(async () => { + await result.current.refreshSessions({ skipIfBusy: true }) + }) + + expect(listSidebarSessions).toHaveBeenCalledTimes(1) + + resolveRequest(sidebar({ sessions: [row('fresh')], total: 1, profile_totals: {} })) + + await act(async () => { + await firstRefresh + }) + + expect($sessions.get().map(session => session.id)).toEqual(['fresh']) + }) + it('keeps the previous $sessions array when the refresh is content-identical', async () => { const rows = [row('a'), row('b')] listSidebarSessions.mockResolvedValue(sidebar({ sessions: rows, total: 2, profile_totals: { default: 2 } })) diff --git a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts index 38850cad3a771..afdfcc9be0743 100644 --- a/apps/desktop/src/app/session/hooks/use-session-list-actions.ts +++ b/apps/desktop/src/app/session/hooks/use-session-list-actions.ts @@ -69,11 +69,18 @@ interface UseSessionListActionsArgs { profileScope: string } +export interface RefreshSessionsOptions { + refreshCronJobs?: boolean + shouldApply?: () => boolean + skipIfBusy?: boolean +} + /** Owns the sidebar's session-list fetching + paging: recents, cron runs/jobs, * and the per-platform messaging slices. Returns the callbacks the controller * wires into the sidebar and refresh effects. */ export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) { const refreshSessionsRequestRef = useRef(0) + const pendingRefreshSessionsRef = useRef(0) // Messaging-platform sessions as their own slice, fetched separately from // local recents so each platform renders a self-managed section and never @@ -137,7 +144,12 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg } }, [profileScope]) - const refreshSessions = useCallback(async () => { + const refreshSessions = useCallback(async (options: RefreshSessionsOptions = {}) => { + if (options.skipIfBusy && pendingRefreshSessionsRef.current > 0) { + return + } + + pendingRefreshSessionsRef.current += 1 const requestId = refreshSessionsRequestRef.current + 1 refreshSessionsRequestRef.current = requestId // The loading flag exists to drive the initial skeletons (they only render @@ -177,7 +189,7 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg messagingExclude: MESSAGING_EXCLUDED_SOURCES }) - if (refreshSessionsRequestRef.current === requestId) { + if (refreshSessionsRequestRef.current === requestId && (options.shouldApply?.() ?? true)) { const recents = result.recents // Signature-gate the swap (same pattern as cron/messaging): a refresh @@ -213,13 +225,19 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg setMessagingTruncated(result.messaging.sessions.length >= MESSAGING_SECTION_LIMIT) } } finally { + pendingRefreshSessionsRef.current = Math.max(0, pendingRefreshSessionsRef.current - 1) + if (showLoading && refreshSessionsRequestRef.current === requestId) { setSessionsLoading(false) } } - // Cron *jobs* are a distinct API (getCronJobs), not a session slice. - void refreshCronJobs() + // Cron *jobs* are a distinct API (getCronJobs), not a session slice. The + // high-frequency cross-client session poll opts out; cron has its own 30s + // visibility poll. + if (options.refreshCronJobs !== false) { + void refreshCronJobs() + } }, [profileScope, refreshCronJobs]) const loadMoreSessions = useCallback(async () => { diff --git a/apps/desktop/src/lib/session-signatures.test.ts b/apps/desktop/src/lib/session-signatures.test.ts index aeecd19b61fbc..69608e0e351f1 100644 --- a/apps/desktop/src/lib/session-signatures.test.ts +++ b/apps/desktop/src/lib/session-signatures.test.ts @@ -42,6 +42,20 @@ describe('sessionMessagesSignature', () => { expect(sessionMessagesSignature([msg('user', 'hi')])).not.toBe(sessionMessagesSignature([msg('user', 'yo')])) }) + it('changes when reasoning metadata changes', () => { + const before = { ...msg('assistant', 'same'), reasoning_content: 'first' } + const after = { ...msg('assistant', 'same'), reasoning_content: 'second' } + + expect(sessionMessagesSignature([before])).not.toBe(sessionMessagesSignature([after])) + }) + + it('changes when tool-call metadata changes', () => { + const before = { ...msg('assistant', 'same'), tool_calls: [{ id: 'call-1', function: { name: 'one' } }] } + const after = { ...msg('assistant', 'same'), tool_calls: [{ id: 'call-1', function: { name: 'two' } }] } + + expect(sessionMessagesSignature([before])).not.toBe(sessionMessagesSignature([after])) + }) + it('changes when a message is appended', () => { const one = [msg('user', 'hi')] expect(sessionMessagesSignature(one)).not.toBe(sessionMessagesSignature([...one, msg('assistant', 'hey')])) diff --git a/apps/desktop/src/lib/session-signatures.ts b/apps/desktop/src/lib/session-signatures.ts index 4ef20e1fab19e..38ec86b743298 100644 --- a/apps/desktop/src/lib/session-signatures.ts +++ b/apps/desktop/src/lib/session-signatures.ts @@ -28,7 +28,9 @@ export function sameCronSignature(a: SessionInfo[], b: SessionInfo[]): boolean { }) } -// FNV-1a over role/timestamp/content. +// FNV-1a over the complete persisted message payload. `toChatMessages()` +// consumes content plus reasoning, tool-call, context, name, and provider-specific +// metadata; omitting any of those can hide a visible transcript change. function hashString(hash: number, value: string): number { let next = hash @@ -44,10 +46,8 @@ function hashString(hash: number, value: string): number { export function sessionMessagesSignature(messages: SessionMessage[]): string { let hash = 2166136261 - for (const m of messages) { - hash = hashString(hash, m.role) - hash = hashString(hash, String(m.timestamp ?? '')) - hash = hashString(hash, typeof m.content === 'string' ? m.content : (JSON.stringify(m.content) ?? '')) + for (const message of messages) { + hash = hashString(hash, JSON.stringify(message) ?? '') } return `${messages.length}:${hash}`