Merge pull request #63948 from NousResearch/bb/salvage-48281-stream-pin

fix(desktop): pin unscoped streams + clear view sync on switch (supersedes #48281)
This commit is contained in:
brooklyn! 2026-07-13 16:12:07 -04:00 committed by GitHub
commit caed552de1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 206 additions and 6 deletions

View file

@ -239,6 +239,7 @@ export function DesktopController() {
const {
activeSessionIdRef,
ensureSessionState,
resetViewSync,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,
@ -619,6 +620,7 @@ export function DesktopController() {
getRouteToken,
navigate,
requestGateway,
resetViewSync,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,

View file

@ -1,5 +1,5 @@
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback } from 'react'
import { type MutableRefObject, useCallback, useRef } from 'react'
import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
@ -9,7 +9,7 @@ import { translateNow } from '@/i18n'
import { type GatewayEventPayload, textPart } from '@/lib/chat-messages'
import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from '@/lib/chat-runtime'
import { playCompletionSound } from '@/lib/completion-sound'
import { gatewayEventRequiresSessionId } from '@/lib/gateway-events'
import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
@ -95,16 +95,27 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
upsertToolCall
} = deps
const unscopedStreamSessionIdRef = useRef<string | null>(null)
return useCallback(
(event: RpcEvent) => {
const payload = event.payload as GatewayEventPayload | undefined
const explicitSid = event.session_id || ''
if (!explicitSid && gatewayEventRequiresSessionId(event.type)) {
const route = resolveGatewayEventSessionId({
activeSessionId: activeSessionIdRef.current,
eventType: event.type,
explicitSessionId: explicitSid,
unscopedStreamSessionId: unscopedStreamSessionIdRef.current
})
unscopedStreamSessionIdRef.current = route.nextUnscopedStreamSessionId
if (route.drop) {
return
}
const sessionId = explicitSid || activeSessionIdRef.current
const sessionId = route.sessionId
const isActiveEvent = !!sessionId && sessionId === activeSessionIdRef.current
if (event.type === 'gateway.ready') {

View file

@ -74,6 +74,7 @@ function Harness({
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
resetViewSync: vi.fn(),
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
@ -229,6 +230,7 @@ function ResumeHarness({
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
resetViewSync: vi.fn(),
runtimeIdByStoredSessionIdRef: runtimeIdByStoredSessionIdRef ?? ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
@ -476,6 +478,7 @@ function BranchHarness({
getRouteToken: () => 'token',
navigate: vi.fn() as never,
requestGateway,
resetViewSync: vi.fn(),
runtimeIdByStoredSessionIdRef: ref(new Map<string, string>()),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),

View file

@ -75,6 +75,7 @@ interface SessionActionsOptions {
getRouteToken: () => string
navigate: NavigateFunction
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
resetViewSync: () => void
runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>>
selectedStoredSessionId: string | null
selectedStoredSessionIdRef: MutableRefObject<string | null>
@ -105,6 +106,7 @@ export function useSessionActions({
getRouteToken,
navigate,
requestGateway,
resetViewSync,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId,
selectedStoredSessionIdRef,
@ -128,6 +130,7 @@ export function useSessionActions({
? normalizeNewChatWorkspaceTarget(draftOptions.workspaceTarget)
: undefined
resetViewSync()
busyRef.current = false
setBusy(false)
setAwaitingResponse(false)
@ -171,7 +174,7 @@ export function useSessionActions({
// Never clear the composer here — ChatBar's per-thread draft swap owns it.
setFreshDraftReady(true)
},
[activeSessionIdRef, busyRef, navigate, selectedStoredSessionIdRef]
[activeSessionIdRef, busyRef, navigate, resetViewSync, selectedStoredSessionIdRef]
)
const createBackendSessionForSend = useCallback(
@ -237,6 +240,7 @@ export function useSessionActions({
return null
}
resetViewSync()
activeSessionIdRef.current = created.session_id
selectedStoredSessionIdRef.current = stored
ensureSessionState(created.session_id, stored)
@ -285,6 +289,7 @@ export function useSessionActions({
getRouteToken,
navigate,
requestGateway,
resetViewSync,
selectedStoredSessionIdRef,
updateSessionState
]
@ -337,6 +342,7 @@ export function useSessionActions({
// resume entry").
setFreshDraftReady(false)
clearNotifications()
resetViewSync()
setSelectedStoredSessionId(storedSessionId)
selectedStoredSessionIdRef.current = storedSessionId
// Optimistically clear any prior resume-failure latch for this session:
@ -667,6 +673,7 @@ export function useSessionActions({
busyRef,
copy,
requestGateway,
resetViewSync,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,

View file

@ -130,6 +130,18 @@ export function useSessionStateCache({
return created
}, [])
const resetViewSync = useCallback(() => {
// Drop any RAF-pending transcript stage so a backgrounded turn cannot
// repaint over the chat the user just switched to (#47709 / #47743).
pendingViewStateRef.current = null
viewSessionIdRef.current = null
if (viewSyncRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(viewSyncRafRef.current)
viewSyncRafRef.current = null
}
}, [])
const flushPendingViewState = useCallback(() => {
const pending = pendingViewStateRef.current
pendingViewStateRef.current = null
@ -306,6 +318,7 @@ export function useSessionStateCache({
return {
activeSessionIdRef,
ensureSessionState,
resetViewSync,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionIdRef,
sessionStateByRuntimeIdRef,

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { gatewayEventRequiresSessionId } from './gateway-events'
import { gatewayEventRequiresSessionId, resolveGatewayEventSessionId } from './gateway-events'
describe('gateway event routing', () => {
it('drops only unscoped subagent events (genuinely background work)', () => {
@ -24,4 +24,75 @@ describe('gateway event routing', () => {
expect(gatewayEventRequiresSessionId('session.info')).toBe(false)
expect(gatewayEventRequiresSessionId(undefined)).toBe(false)
})
it('keeps unscoped stream events pinned to the session that started them', () => {
const started = resolveGatewayEventSessionId({
activeSessionId: 'session-a',
eventType: 'message.start',
explicitSessionId: '',
unscopedStreamSessionId: null
})
expect(started).toEqual({
drop: false,
nextUnscopedStreamSessionId: 'session-a',
sessionId: 'session-a'
})
const delta = resolveGatewayEventSessionId({
activeSessionId: 'session-b',
eventType: 'message.delta',
explicitSessionId: '',
unscopedStreamSessionId: started.nextUnscopedStreamSessionId
})
expect(delta).toEqual({
drop: false,
nextUnscopedStreamSessionId: 'session-a',
sessionId: 'session-a'
})
const completed = resolveGatewayEventSessionId({
activeSessionId: 'session-b',
eventType: 'message.complete',
explicitSessionId: '',
unscopedStreamSessionId: delta.nextUnscopedStreamSessionId
})
expect(completed).toEqual({
drop: false,
nextUnscopedStreamSessionId: null,
sessionId: 'session-a'
})
})
it('routes a new unscoped stream start to the currently active session', () => {
const routed = resolveGatewayEventSessionId({
activeSessionId: 'session-b',
eventType: 'message.start',
explicitSessionId: '',
unscopedStreamSessionId: 'session-a'
})
expect(routed).toEqual({
drop: false,
nextUnscopedStreamSessionId: 'session-b',
sessionId: 'session-b'
})
})
it('keeps explicit events scoped and clears a matching pinned stream on completion', () => {
const routed = resolveGatewayEventSessionId({
activeSessionId: 'session-b',
eventType: 'message.complete',
explicitSessionId: 'session-a',
unscopedStreamSessionId: 'session-a'
})
expect(routed).toEqual({
drop: false,
nextUnscopedStreamSessionId: null,
sessionId: 'session-a'
})
})
})

View file

@ -11,6 +11,34 @@ function asRecord(payload: unknown): Record<string, unknown> {
return payload && typeof payload === 'object' ? (payload as Record<string, unknown>) : {}
}
/**
* Unscoped stream events that must stay pinned to the session that received
* ``message.start`` after the user switches chats mid-turn (#47709 / #48281).
* Without this, ``explicitSid || activeSessionId`` reattributes live deltas to
* the newly focused chat.
*/
const UNSCOPED_STREAM_EVENT_TYPES = new Set([
'approval.request',
'browser.progress',
'clarify.request',
'error',
'message.complete',
'message.delta',
'message.start',
'reasoning.available',
'reasoning.delta',
'secret.request',
'status.update',
'sudo.request',
'thinking.delta',
'tool.complete',
'tool.generating',
'tool.progress',
'tool.start'
])
const UNSCOPED_STREAM_END_EVENT_TYPES = new Set(['error', 'message.complete'])
/**
* Whether an unscoped event (no `session_id`) must be dropped rather than
* attributed to the focused chat.
@ -27,6 +55,71 @@ export function gatewayEventRequiresSessionId(eventType: string | undefined): bo
return eventType?.startsWith('subagent.') ?? false
}
export interface GatewayEventSessionRouteInput {
activeSessionId: null | string
eventType: string | undefined
explicitSessionId: string
unscopedStreamSessionId: null | string
}
export interface GatewayEventSessionRoute {
drop: boolean
nextUnscopedStreamSessionId: null | string
sessionId: null | string
}
/**
* Resolve which runtime session owns a gateway event.
*
* Explicit ``session_id`` always wins. Unscoped stream events pin to the
* session that received ``message.start`` so a mid-turn chat switch cannot
* steal live deltas / tool events onto the newly focused transcript.
*/
export function resolveGatewayEventSessionId({
activeSessionId,
eventType,
explicitSessionId,
unscopedStreamSessionId
}: GatewayEventSessionRouteInput): GatewayEventSessionRoute {
if (explicitSessionId) {
const nextUnscopedStreamSessionId =
eventType && UNSCOPED_STREAM_END_EVENT_TYPES.has(eventType) && explicitSessionId === unscopedStreamSessionId
? null
: unscopedStreamSessionId
return {
drop: false,
nextUnscopedStreamSessionId,
sessionId: explicitSessionId
}
}
if (gatewayEventRequiresSessionId(eventType)) {
return {
drop: true,
nextUnscopedStreamSessionId: unscopedStreamSessionId,
sessionId: null
}
}
const streamEvent = eventType ? UNSCOPED_STREAM_EVENT_TYPES.has(eventType) : false
const sessionId =
eventType === 'message.start' ? activeSessionId : streamEvent ? unscopedStreamSessionId || activeSessionId : activeSessionId
let nextUnscopedStreamSessionId = unscopedStreamSessionId
if (eventType === 'message.start' && activeSessionId) {
nextUnscopedStreamSessionId = activeSessionId
} else if (eventType && UNSCOPED_STREAM_END_EVENT_TYPES.has(eventType)) {
nextUnscopedStreamSessionId = null
}
return {
drop: false,
nextUnscopedStreamSessionId,
sessionId
}
}
export function gatewayEventCompletedFileDiff(event: RpcEventLike): boolean {
if (event.type !== 'tool.complete') {
return false