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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user