fix(sessions): snapshot send target so a project switch cannot reroute a pending send (#2871)

Snapshot the new-session draft (and keep the existing-session target captured) at
submit time, then use that snapshot for draft materialization and routing instead
of re-reading live selection state after async preparation.

Fixes #2222
Fixes #2315
This commit is contained in:
Serhii Dziupin
2026-08-13 12:31:33 +03:00
committed by GitHub
parent 8a6eca5597
commit d3011a6247
3 changed files with 138 additions and 7 deletions
+14 -3
View File
@@ -962,6 +962,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const queuedMessageId = options?.queuedMessageId;
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
const capturedTarget = messageQueueTarget;
// Snapshot the draft and current-session identity before the first
// async gap so a later sidebar selection cannot reroute the send.
const capturedDraftSnapshot = newSessionDraftOpen ? { ...newSessionDraft } : null;
const inputSnapshot = options?.presetText != null
? {
message: options.presetText,
@@ -1034,9 +1037,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
}
const sendMessageOptions = capturedTarget
? { target: capturedTarget, ...(delivery ? { delivery } : {}) }
: delivery ? { delivery } : undefined;
const sendMessageOptions: {
target?: NonNullable<typeof capturedTarget>;
draftSnapshot?: NonNullable<typeof capturedDraftSnapshot>;
delivery?: 'steer';
} | undefined = (capturedTarget || capturedDraftSnapshot || delivery)
? {
...(capturedTarget ? { target: capturedTarget } : {}),
...(capturedDraftSnapshot ? { draftSnapshot: capturedDraftSnapshot } : {}),
...(delivery ? { delivery } : {}),
}
: undefined;
// Inline review comments and synthetic context are consumed before
// assembly so a failed send can restore exactly what it took.
@@ -435,6 +435,124 @@ describe('createSession draft lifecycle', () => {
});
});
// ---------------------------------------------------------------------------
// Issues #2222 and #2315 — send target must be snapshotted at submit time so a
// later sidebar/project selection cannot reroute a pending draft or session
// send to whichever session happens to be current when the async work resumes.
// ---------------------------------------------------------------------------
describe('sendMessage draft snapshot (issues #2222 / #2315)', () => {
const sendMessageCalls = [];
const createSessionCalls = [];
let originalSendMessage;
let originalCreateSession;
beforeEach(() => {
sendMessageCalls.length = 0;
createSessionCalls.length = 0;
const childStore = {
getState: () => ({ session: [], message: {}, part: {}, session_status: {} }),
setState: () => {},
};
const childStores = {
children: new Map(),
ensureChild: () => childStore,
getChild: () => childStore,
};
setActionRefs(opencodeClient, childStores, () => '/projects/alpha');
setOptimisticRefs(() => {}, () => {});
useConfigStore.setState({ isConnected: true });
originalSendMessage = opencodeClient.sendMessage;
originalCreateSession = opencodeClient.createSession;
opencodeClient.sendMessage = async (params) => {
sendMessageCalls.push(params);
return 'msg';
};
opencodeClient.createSession = async (_params, directory) => {
createSessionCalls.push(directory);
return { id: 'session-materialized', directory: directory ?? '/projects/alpha' };
};
});
afterEach(() => {
opencodeClient.sendMessage = originalSendMessage;
opencodeClient.createSession = originalCreateSession;
useSessionUIStore.setState({
currentSessionId: null,
currentSessionDirectory: null,
newSessionDraft: { open: false, directoryOverride: null, parentID: null },
});
});
test('draft send snapshots the draft; switching to another project mid-flight still targets the materialized session', async () => {
const draftSnapshot = {
open: true,
directoryOverride: '/projects/alpha',
parentID: null,
title: 'Project A draft',
};
useSessionUIStore.setState({
currentSessionId: null,
currentSessionDirectory: null,
newSessionDraft: draftSnapshot,
});
const sendPromise = useSessionUIStore.getState().sendMessage(
'message for project A',
'provider-a',
'model-a',
undefined,
undefined,
undefined,
undefined,
undefined,
'normal',
{ draftSnapshot },
);
// A sidebar switch while the send is still in flight must not reroute it.
useSessionUIStore.getState().setCurrentSession('session-project-b', '/projects/beta');
await sendPromise;
expect(createSessionCalls).toHaveLength(1);
expect(createSessionCalls[0]).toBe('/projects/alpha');
expect(sendMessageCalls).toHaveLength(1);
expect(sendMessageCalls[0].id).toBe('session-materialized');
expect(sendMessageCalls[0].directory).toBe('/projects/alpha');
});
test('existing-session send keeps the submit-time target even when selection changes', async () => {
useSessionUIStore.setState({
currentSessionId: 'session-project-a',
currentSessionDirectory: '/projects/alpha',
newSessionDraft: { open: false, directoryOverride: null, parentID: null },
});
const sendPromise = useSessionUIStore.getState().sendMessage(
'message for project A',
'provider-a',
'model-a',
undefined,
undefined,
undefined,
undefined,
undefined,
'normal',
{ target: { runtimeKey: getRuntimeKey(), sessionId: 'session-project-a', directory: '/projects/alpha' } },
);
useSessionUIStore.getState().setCurrentSession('session-project-b', '/projects/beta');
await sendPromise;
expect(sendMessageCalls).toHaveLength(1);
expect(sendMessageCalls[0].id).toBe('session-project-a');
expect(sendMessageCalls[0].directory).toBe('/projects/alpha');
});
});
describe('routeMessage skill invocation', () => {
// OpenCode registers every skill as a command (source: "skill"), so a skill
// selected from the slash menu must be dispatched via session.command so its
+6 -4
View File
@@ -231,6 +231,8 @@ type SendMessageOptions = {
target?: CapturedSendTarget
sessionId?: string
directory?: string
/** Immutable copy of the new-session draft at submit time; used instead of the live draft. */
draftSnapshot?: NewSessionDraftState
delivery?: 'steer'
}
@@ -609,9 +611,9 @@ export async function materializeOpenDraftSession(selection: {
modelID: string
agent?: string
variant?: string
}): Promise<MaterializedDraftSession | null> {
}, draftOverride?: NewSessionDraftState): Promise<MaterializedDraftSession | null> {
const store = useSessionUIStore.getState()
const draft = store.newSessionDraft
const draft = draftOverride ?? store.newSessionDraft
if (!draft?.open) return null
const draftPermissionAutoAcceptEnabled = draft.permissionAutoAcceptEnabled === true
@@ -1224,7 +1226,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
set({ pendingChangesBarDismissed: map });
}
const draft = get().newSessionDraft
const draft = options?.draftSnapshot ?? get().newSessionDraft
const trimmedAgent = typeof agent === "string" && agent.trim().length > 0 ? agent.trim() : undefined
const goalArm = inputMode !== "shell" && content.trim().length > 0
@@ -1282,7 +1284,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
modelID,
agent: trimmedAgent,
variant,
})
}, options?.draftSnapshot)
if (!createdDraftSession) throw new Error("Failed to create session")
const mergedAdditionalParts = createdDraftSession.syntheticParts?.length