diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx index 34472019e6..835a9bb867 100644 --- a/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/SessionsSection.tsx @@ -45,6 +45,7 @@ import { SessionExecutionState } from '@/flow_chat/state-machine/types'; import { i18nService } from '@/infrastructure/i18n'; import { resolveSessionTitle } from '@/flow_chat/utils/sessionTitle'; import { isSessionNavRowActive } from './sessionNavSelection'; +import { isSessionRowPointerTarget } from './sessionOpenPointer'; import { deriveSessionReviewActivity, isReviewActivityBlocking, @@ -1168,6 +1169,13 @@ const SessionsSection: React.FC = ({ return; } + // React portal events still bubble through the component tree. Only a + // pointer physically inside the row is an intent to open that session; + // menu actions rendered in the overlay host must not start hydration. + if (!isSessionRowPointerTarget(event.currentTarget, event.target)) { + return; + } + const target = event.target as HTMLElement | null; if (target?.closest('.bitfun-nav-panel__inline-item-actions, .bitfun-nav-panel__inline-item-edit')) { return; diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionOpenPointer.test.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionOpenPointer.test.ts new file mode 100644 index 0000000000..d62fb188ba --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionOpenPointer.test.ts @@ -0,0 +1,28 @@ +// @vitest-environment jsdom + +import { describe, expect, it } from 'vitest'; + +import { isSessionRowPointerTarget } from './sessionOpenPointer'; + +describe('isSessionRowPointerTarget', () => { + it('accepts pointer targets rendered inside the session row', () => { + const row = document.createElement('div'); + const label = document.createElement('span'); + row.append(label); + + expect(isSessionRowPointerTarget(row, label)).toBe(true); + }); + + it('rejects pointer targets rendered through a portal outside the session row', () => { + const row = document.createElement('div'); + const portalMenu = document.createElement('div'); + const deleteButton = document.createElement('button'); + portalMenu.append(deleteButton); + document.body.append(row, portalMenu); + + expect(isSessionRowPointerTarget(row, deleteButton)).toBe(false); + + row.remove(); + portalMenu.remove(); + }); +}); diff --git a/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionOpenPointer.ts b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionOpenPointer.ts new file mode 100644 index 0000000000..4b6d21b2db --- /dev/null +++ b/src/web-ui/src/app/components/NavPanel/sections/sessions/sessionOpenPointer.ts @@ -0,0 +1,6 @@ +export function isSessionRowPointerTarget( + row: HTMLElement, + target: EventTarget | null, +): target is Node { + return target instanceof Node && row.contains(target); +} diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx index 5e5169657f..156d88d1c7 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.history-state.test.tsx @@ -25,4 +25,9 @@ describe('ModernFlowChatContainer natural navigation contract', () => { expect(componentSource).toContain('onViewportRestoreSettled={handleViewportRestoreSettled}'); expect(componentSource).toContain('viewportRestorePendingSessionId === sessionId'); }); + + it('dismisses a pending open shield when its global transition is cancelled', () => { + expect(componentSource).toContain('subscribeHistorySessionOpenTransition'); + expect(componentSource).toContain("transition?.sessionId !== current.sessionId ? null : current"); + }); }); diff --git a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx index cafcb24957..9b41024de0 100644 --- a/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx +++ b/src/web-ui/src/flow_chat/components/modern/ModernFlowChatContainer.tsx @@ -92,6 +92,7 @@ import { getHistorySessionOpenTransitionSnapshot, hasRenderableSessionContent, HISTORY_SESSION_OPEN_INTENT_EVENT, + subscribeHistorySessionOpenTransition, type HistorySessionOpenIntentDetail, } from '../../services/sessionOpenIntent'; import { @@ -834,6 +835,13 @@ export const ModernFlowChatContainer: React.FC = ( }; }, []); + useEffect(() => subscribeHistorySessionOpenTransition(() => { + const transition = getHistorySessionOpenTransitionSnapshot(); + setPendingHistoryOpenSession(current => ( + current && transition?.sessionId !== current.sessionId ? null : current + )); + }), []); + useEffect(() => { if (!pendingHistoryOpenSession) { return; diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts index 6552fa5648..095f78cbea 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/EventHandlerModule.ts @@ -57,6 +57,10 @@ import { reconcileSubagentIdentitiesFromFlowState } from '../../subagent-identit import { createTab } from '@/shared/utils/tabUtils'; import { splitFilePathAndContent } from '@/shared/utils/partialJsonParser'; import { interruptedTurnRecoveryGate } from '../interruptedTurnRecoveryGate'; +import { + clearHistorySessionOpenTransition, + clearRecentHistorySessionOpenIntent, +} from '../sessionOpenIntent'; const pendingImageAnalysisTurns = new Map(); import { @@ -1438,6 +1442,11 @@ function handleSessionDeleted(context: FlowChatContext, event: any): void { const removedSessionIds = store.getCascadeSessionIds(sessionId); if (removedSessionIds.length === 0) return; + removedSessionIds.forEach(removedSessionId => { + clearRecentHistorySessionOpenIntent(removedSessionId); + clearHistorySessionOpenTransition(removedSessionId); + }); + log.info('Remote session deleted', { sessionId }); removedSessionIds.forEach(id => { clearPendingTurnCompletion(context, id); diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts index 38442ed547..f0d77dbbdd 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.test.ts @@ -18,8 +18,10 @@ import { LOCAL_SURFACE_ID, } from '@/infrastructure/peer-device/deviceSurface'; import { + clearHistorySessionOpenTransition, clearRecentHistorySessionOpenIntent, dispatchHistorySessionOpenIntent, + getHistorySessionOpenTransitionSnapshot, } from '../sessionOpenIntent'; import type { Session } from '../../types/flow-chat'; import type { ReviewTeamRunManifest } from '@/shared/services/reviewTeamService'; @@ -578,6 +580,7 @@ describe('SessionModule historical session coordination', () => { afterEach(async () => { await vi.runOnlyPendingTimersAsync(); clearRecentHistorySessionOpenIntent(); + clearHistorySessionOpenTransition(); vi.useRealTimers(); vi.clearAllMocks(); }); @@ -1156,6 +1159,27 @@ describe('SessionModule historical session coordination', () => { expect(persistenceMocks.cleanupSaveState).toHaveBeenCalledWith(context, 'active-1'); }); + it('cancels a speculative history-open transition when deleting its target', async () => { + const historicalSession = createSession({ + sessionId: 'history-delete', + isHistorical: true, + historyState: 'metadata-only', + dialogTurns: [], + }); + const { context } = createContext(historicalSession, { + activeSessionId: null, + }); + + dispatchHistorySessionOpenIntent(historicalSession.sessionId, 'Saved session'); + expect(getHistorySessionOpenTransitionSnapshot()).toMatchObject({ + sessionId: historicalSession.sessionId, + }); + + await deleteChatSession(context, historicalSession.sessionId); + + expect(getHistorySessionOpenTransitionSnapshot()).toBeNull(); + }); + it('tombstones a deleted dispatch projection instead of deleting a local session', async () => { const session = createSession({ sessionId: 'dispatch-session', diff --git a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts index a2a737c09c..af4b0ece73 100644 --- a/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts +++ b/src/web-ui/src/flow_chat/services/flow-chat-manager/SessionModule.ts @@ -37,6 +37,8 @@ import { } from '../../utils/sessionTitle'; import { buildCreateSessionRelationship } from '../../utils/sessionMetadata'; import { + clearHistorySessionOpenTransition, + clearRecentHistorySessionOpenIntent, consumeRecentHistorySessionOpenIntent, hasRenderableSessionContent, } from '../sessionOpenIntent'; @@ -857,6 +859,13 @@ export async function deleteChatSession( stateBeforeDelete.activeSessionId && removedSessionIdSet.has(stateBeforeDelete.activeSessionId) ); + // Deletion cancels any speculative pointer-down transition before awaiting + // the backend. Otherwise a target that is never activated can leave the + // history-open shield visible until its safety timeout. + removedSessionIds.forEach(removedSessionId => { + clearRecentHistorySessionOpenIntent(removedSessionId); + clearHistorySessionOpenTransition(removedSessionId); + }); const session = stateBeforeDelete.sessions.get(sessionId); await driverForSession(sessionId, session).deleteSession(context, sessionId, { removedSessionIds,