Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -1168,6 +1169,13 @@ const SessionsSection: React.FC<SessionsSectionProps> = ({
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export function isSessionRowPointerTarget(
row: HTMLElement,
target: EventTarget | null,
): target is Node {
return target instanceof Node && row.contains(target);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,7 @@ import {
getHistorySessionOpenTransitionSnapshot,
hasRenderableSessionContent,
HISTORY_SESSION_OPEN_INTENT_EVENT,
subscribeHistorySessionOpenTransition,
type HistorySessionOpenIntentDetail,
} from '../../services/sessionOpenIntent';
import {
Expand Down Expand Up @@ -834,6 +835,13 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (
};
}, []);

useEffect(() => subscribeHistorySessionOpenTransition(() => {
const transition = getHistorySessionOpenTransitionSnapshot();
setPendingHistoryOpenSession(current => (
current && transition?.sessionId !== current.sessionId ? null : current
));
}), []);

useEffect(() => {
if (!pendingHistoryOpenSession) {
return;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string>();
import {
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -578,6 +580,7 @@ describe('SessionModule historical session coordination', () => {
afterEach(async () => {
await vi.runOnlyPendingTimersAsync();
clearRecentHistorySessionOpenIntent();
clearHistorySessionOpenTransition();
vi.useRealTimers();
vi.clearAllMocks();
});
Expand Down Expand Up @@ -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',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import {
} from '../../utils/sessionTitle';
import { buildCreateSessionRelationship } from '../../utils/sessionMetadata';
import {
clearHistorySessionOpenTransition,
clearRecentHistorySessionOpenIntent,
consumeRecentHistorySessionOpenIntent,
hasRenderableSessionContent,
} from '../sessionOpenIntent';
Expand Down Expand Up @@ -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,
Expand Down