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 queuedMessageId = options?.queuedMessageId;
|
||||||
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
|
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
|
||||||
const capturedTarget = messageQueueTarget;
|
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
|
const inputSnapshot = options?.presetText != null
|
||||||
? {
|
? {
|
||||||
message: options.presetText,
|
message: options.presetText,
|
||||||
@@ -1034,9 +1037,17 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const sendMessageOptions = capturedTarget
|
const sendMessageOptions: {
|
||||||
? { target: capturedTarget, ...(delivery ? { delivery } : {}) }
|
target?: NonNullable<typeof capturedTarget>;
|
||||||
: delivery ? { delivery } : undefined;
|
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
|
// Inline review comments and synthetic context are consumed before
|
||||||
// assembly so a failed send can restore exactly what it took.
|
// 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', () => {
|
describe('routeMessage skill invocation', () => {
|
||||||
// OpenCode registers every skill as a command (source: "skill"), so a skill
|
// 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
|
// selected from the slash menu must be dispatched via session.command so its
|
||||||
|
|||||||
@@ -231,6 +231,8 @@ type SendMessageOptions = {
|
|||||||
target?: CapturedSendTarget
|
target?: CapturedSendTarget
|
||||||
sessionId?: string
|
sessionId?: string
|
||||||
directory?: string
|
directory?: string
|
||||||
|
/** Immutable copy of the new-session draft at submit time; used instead of the live draft. */
|
||||||
|
draftSnapshot?: NewSessionDraftState
|
||||||
delivery?: 'steer'
|
delivery?: 'steer'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -609,9 +611,9 @@ export async function materializeOpenDraftSession(selection: {
|
|||||||
modelID: string
|
modelID: string
|
||||||
agent?: string
|
agent?: string
|
||||||
variant?: string
|
variant?: string
|
||||||
}): Promise<MaterializedDraftSession | null> {
|
}, draftOverride?: NewSessionDraftState): Promise<MaterializedDraftSession | null> {
|
||||||
const store = useSessionUIStore.getState()
|
const store = useSessionUIStore.getState()
|
||||||
const draft = store.newSessionDraft
|
const draft = draftOverride ?? store.newSessionDraft
|
||||||
if (!draft?.open) return null
|
if (!draft?.open) return null
|
||||||
const draftPermissionAutoAcceptEnabled = draft.permissionAutoAcceptEnabled === true
|
const draftPermissionAutoAcceptEnabled = draft.permissionAutoAcceptEnabled === true
|
||||||
|
|
||||||
@@ -1224,7 +1226,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
set({ pendingChangesBarDismissed: map });
|
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 trimmedAgent = typeof agent === "string" && agent.trim().length > 0 ? agent.trim() : undefined
|
||||||
|
|
||||||
const goalArm = inputMode !== "shell" && content.trim().length > 0
|
const goalArm = inputMode !== "shell" && content.trim().length > 0
|
||||||
@@ -1282,7 +1284,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
|||||||
modelID,
|
modelID,
|
||||||
agent: trimmedAgent,
|
agent: trimmedAgent,
|
||||||
variant,
|
variant,
|
||||||
})
|
}, options?.draftSnapshot)
|
||||||
if (!createdDraftSession) throw new Error("Failed to create session")
|
if (!createdDraftSession) throw new Error("Failed to create session")
|
||||||
|
|
||||||
const mergedAdditionalParts = createdDraftSession.syntheticParts?.length
|
const mergedAdditionalParts = createdDraftSession.syntheticParts?.length
|
||||||
|
|||||||
Reference in New Issue
Block a user