diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 89c7fcf6..41aa6f01 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -962,6 +962,9 @@ const ChatInputComponent: React.FC = ({ 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 = ({ onOpenSettings, scrollTo } } - const sendMessageOptions = capturedTarget - ? { target: capturedTarget, ...(delivery ? { delivery } : {}) } - : delivery ? { delivery } : undefined; + const sendMessageOptions: { + target?: NonNullable; + draftSnapshot?: NonNullable; + 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. diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index cd7dcdd1..23ace4aa 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -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 diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 429dcb4f..94d875bc 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -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 { +}, draftOverride?: NewSessionDraftState): Promise { 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()((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()((set, get) => ({ modelID, agent: trimmedAgent, variant, - }) + }, options?.draftSnapshot) if (!createdDraftSession) throw new Error("Failed to create session") const mergedAdditionalParts = createdDraftSession.syntheticParts?.length