From 73ab36bc9144c93030eb90cc36a8eda6917644dc Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Mon, 25 May 2026 00:29:52 +0300 Subject: [PATCH] fix: send queued messages to the original session Prevents queued messages from being sent to a newly opened session Adds explicit session targeting for queued auto-send Covers the behavior with a unit test --- .../hooks/useQueuedMessageAutoSend.test.ts | 48 ++++- .../ui/src/hooks/useQueuedMessageAutoSend.ts | 44 +++-- packages/ui/src/stores/types/sessionTypes.ts | 2 +- packages/ui/src/sync/session-ui-store.ts | 167 ++++++++++-------- 4 files changed, 173 insertions(+), 88 deletions(-) diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts index 9ab36aa5..2a4454da 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts @@ -3,6 +3,7 @@ import type { Agent } from '@opencode-ai/sdk/v2'; import type { QueuedMessage } from '../stores/messageQueueStore'; let visibleAgents: Agent[] = []; +const sendMessageCalls: unknown[][] = []; const getVisibleAgentsMock = mock(() => visibleAgents); @@ -14,11 +15,24 @@ mock.module('@/stores/useConfigStore', () => ({ }, })); -import { buildQueuedAutoSendPayload } from './useQueuedMessageAutoSend'; +mock.module('@/sync/session-ui-store', () => ({ + useSessionUIStore: { + getState: () => ({ + sendMessage: (...args: unknown[]) => { + sendMessageCalls.push(args); + return Promise.resolve(); + }, + sessionAbortFlags: new Map(), + }), + }, +})); + +import { buildQueuedAutoSendPayload, sendQueuedAutoSendPayload } from './useQueuedMessageAutoSend'; describe('buildQueuedAutoSendPayload', () => { beforeEach(() => { visibleAgents = []; + sendMessageCalls.length = 0; }); test('returns only the first queued message for auto-send', () => { @@ -101,4 +115,36 @@ describe('buildQueuedAutoSendPayload', () => { expect(payload?.primaryAttachments).toHaveLength(1); expect(payload?.primaryAttachments[0]?.filename).toBe('notes.txt'); }); + + test('auto-send targets the queued session explicitly', async () => { + const payload = buildQueuedAutoSendPayload([ + { + id: 'queued-1', + content: 'queued message', + createdAt: 1, + }, + ]); + + expect(payload).not.toBeNull(); + await sendQueuedAutoSendPayload('session-original', payload!, { + providerID: 'provider-1', + modelID: 'model-1', + agent: 'agent-1', + variant: 'variant-1', + }); + + expect(sendMessageCalls.length).toBe(1); + expect(sendMessageCalls[0]).toEqual([ + 'queued message', + 'provider-1', + 'model-1', + 'agent-1', + [], + undefined, + undefined, + 'variant-1', + 'normal', + { sessionId: 'session-original' }, + ]); + }); }); diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts index 3ca6a7ed..7ca711cd 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -38,6 +38,33 @@ export const buildQueuedAutoSendPayload = (queue: QueuedMessage[]) => { }; }; +type QueuedAutoSendPayload = NonNullable>; +type ResolvedQueuedSendConfig = { + providerID: string; + modelID: string; + agent?: string; + variant?: string; +}; + +export const sendQueuedAutoSendPayload = ( + sessionId: string, + payload: QueuedAutoSendPayload, + resolved: ResolvedQueuedSendConfig, +) => { + return useSessionUIStore.getState().sendMessage( + payload.primaryText, + resolved.providerID, + resolved.modelID, + resolved.agent, + payload.primaryAttachments, + payload.agentMentionName, + undefined, + resolved.variant, + 'normal', + { sessionId }, + ); +}; + const resolveSessionSendConfig = (sessionId: string) => { const context = useContextStore.getState(); const config = useConfigStore.getState(); @@ -128,17 +155,12 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled? inFlightSessionsRef.current.add(sessionId); try { - await useSessionUIStore.getState().sendMessage( - payload.primaryText, - resolved.providerID, - resolved.modelID, - resolved.agent, - payload.primaryAttachments, - payload.agentMentionName, - undefined, - resolved.variant, - 'normal' - ); + await sendQueuedAutoSendPayload(sessionId, payload, { + providerID: resolved.providerID, + modelID: resolved.modelID, + agent: resolved.agent, + variant: resolved.variant, + }); const removeFromQueue = useMessageQueueStore.getState().removeFromQueue; removeFromQueue(sessionId, payload.queuedMessageId); diff --git a/packages/ui/src/stores/types/sessionTypes.ts b/packages/ui/src/stores/types/sessionTypes.ts index d2827ff4..5a7c3bd8 100644 --- a/packages/ui/src/stores/types/sessionTypes.ts +++ b/packages/ui/src/stores/types/sessionTypes.ts @@ -246,7 +246,7 @@ export interface SessionStore { unshareSession: (id: string) => Promise; setCurrentSession: (id: string | null) => void; loadMessages: (sessionId: string, limit?: number) => Promise; - sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell') => Promise; + sendMessage: (content: string, providerID: string, modelID: string, agent?: string, attachments?: AttachedFile[], agentMentionName?: string, additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: 'normal' | 'shell', options?: { sessionId?: string }) => Promise; abortCurrentOperation: (sessionIdOverride?: string) => Promise; acknowledgeSessionAbort: (sessionId: string) => void; armAbortPrompt: (durationMs?: number) => number | null; diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index a58197c1..727523be 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -63,6 +63,7 @@ export type { AttachedFile } function routeMessage(params: { sessionId: string + directory?: string | null content: string providerID: string modelID: string @@ -73,74 +74,86 @@ function routeMessage(params: { files?: Array<{ type: "file"; mime: string; url: string; filename: string }> additionalParts?: Array<{ text: string; synthetic?: boolean; files?: Array<{ type: "file"; mime: string; url: string; filename: string }> }> }): Promise { - if (params.inputMode === "shell") { - const sdk = opencodeClient.getSdkClient() - const dir = opencodeClient.getDirectory() || undefined - return sdk.session.shell({ - sessionID: params.sessionId, - directory: dir, - agent: params.agent, - model: { providerID: params.providerID, modelID: params.modelID }, - command: params.content, - }).then(() => {}) - } - - // Slash commands — fire and forget, SSE delivers messages and status - if (params.content.startsWith("/")) { - const [head, ...tail] = params.content.split(" ") - const cmdName = head.slice(1) - - const dirState = getDirectoryState() - const syncCommands = dirState?.command ?? [] - const storeCommands = useCommandsStore.getState().commands - - const isCommand = syncCommands.find((c) => c.name === cmdName) - || storeCommands.find((c) => c.name === cmdName) - - if (isCommand) { - return optimisticSend({ - sessionId: params.sessionId, - content: params.content, - providerID: params.providerID, - modelID: params.modelID, + const run = (): Promise => { + if (params.inputMode === "shell") { + const sdk = opencodeClient.getSdkClient() + const dir = opencodeClient.getDirectory() || undefined + return sdk.session.shell({ + sessionID: params.sessionId, + directory: dir, agent: params.agent, - files: params.files, - send: (messageID) => opencodeClient.sendCommand({ - id: params.sessionId, + model: { providerID: params.providerID, modelID: params.modelID }, + command: params.content, + }).then(() => {}) + } + + // Slash commands — fire and forget, SSE delivers messages and status + if (params.content.startsWith("/")) { + const [head, ...tail] = params.content.split(" ") + const cmdName = head.slice(1) + + const dirState = getDirectoryState(params.directory ?? undefined) + const syncCommands = dirState?.command ?? [] + const storeCommands = useCommandsStore.getState().commands + + const isCommand = syncCommands.find((c) => c.name === cmdName) + || storeCommands.find((c) => c.name === cmdName) + + if (isCommand) { + return optimisticSend({ + sessionId: params.sessionId, + content: params.content, providerID: params.providerID, modelID: params.modelID, - command: cmdName, - arguments: tail.join(" "), agent: params.agent, - variant: params.variant, files: params.files, - messageId: messageID, - }).then(() => {}), - }) + send: (messageID) => opencodeClient.sendCommand({ + id: params.sessionId, + providerID: params.providerID, + modelID: params.modelID, + command: cmdName, + arguments: tail.join(" "), + agent: params.agent, + variant: params.variant, + files: params.files, + messageId: messageID, + }).then(() => {}), + }) + } } - } - // Normal prompt — optimistic insert so message appears instantly - return optimisticSend({ - sessionId: params.sessionId, - content: params.content, - providerID: params.providerID, - modelID: params.modelID, - agent: params.agent, - files: params.files, - send: (messageID) => opencodeClient.sendMessage({ - id: params.sessionId, + // Normal prompt — optimistic insert so message appears instantly + return optimisticSend({ + sessionId: params.sessionId, + content: params.content, providerID: params.providerID, modelID: params.modelID, - text: params.content, agent: params.agent, - agentMentions: params.agentMentionName ? [{ name: params.agentMentionName }] : undefined, - variant: params.variant, files: params.files, - additionalParts: params.additionalParts, - messageId: messageID, - }).then(() => {}), - }) + send: (messageID) => opencodeClient.sendMessage({ + id: params.sessionId, + providerID: params.providerID, + modelID: params.modelID, + text: params.content, + agent: params.agent, + agentMentions: params.agentMentionName ? [{ name: params.agentMentionName }] : undefined, + variant: params.variant, + files: params.files, + additionalParts: params.additionalParts, + messageId: messageID, + }).then(() => {}), + }) + } + + if (params.directory !== undefined) { + return opencodeClient.withDirectory(params.directory, run) + } + + return run() +} + +type SendMessageOptions = { + sessionId?: string } function notifyMessageSent(sessionId: string): void { @@ -239,6 +252,7 @@ export type SessionUIState = { additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: "normal" | "shell", + options?: SendMessageOptions, ) => Promise createSession: (title?: string, directoryOverride?: string | null, parentID?: string | null) => Promise @@ -703,9 +717,10 @@ export const useSessionUIStore = create()((set, get) => ({ additionalParts?: Array<{ text: string; attachments?: AttachedFile[]; synthetic?: boolean }>, variant?: string, inputMode?: "normal" | "shell", + options?: SendMessageOptions, ) => { // Clear non-Git changed-files bar on new user message for current session - const sid = get().currentSessionId; + const sid = options?.sessionId ?? get().currentSessionId; if (sid) { const map = new Map(get().pendingChangesBarDismissed); map.delete(sid); @@ -716,7 +731,7 @@ export const useSessionUIStore = create()((set, get) => ({ const trimmedAgent = typeof agent === "string" && agent.trim().length > 0 ? agent.trim() : undefined // ---- New session from draft ---- - if (draft?.open) { + if (!options?.sessionId && draft?.open) { const draftTargetFolderId = draft.targetFolderId let draftDirectoryOverride = draft.bootstrapPendingDirectory ?? draft.directoryOverride ?? null const draftProjectId = draft.selectedProjectId ?? null @@ -788,6 +803,7 @@ export const useSessionUIStore = create()((set, get) => ({ await routeMessage({ sessionId: created.id, + directory: createdDirectory, content, providerID, modelID, @@ -811,24 +827,24 @@ export const useSessionUIStore = create()((set, get) => ({ } // ---- Existing session ---- - const currentSessionId = get().currentSessionId - const sessionAgentSelection = currentSessionId - ? useSelectionStore.getState().getSessionAgentSelection(currentSessionId) + const targetSessionId = options?.sessionId ?? get().currentSessionId + const sessionAgentSelection = targetSessionId + ? useSelectionStore.getState().getSessionAgentSelection(targetSessionId) : null const configAgentName = useConfigStore.getState().currentAgentName const effectiveAgent = trimmedAgent || sessionAgentSelection || configAgentName || undefined - if (currentSessionId && effectiveAgent) { - useSelectionStore.getState().saveSessionAgentSelection(currentSessionId, effectiveAgent) - useSelectionStore.getState().saveAgentModelVariantForSession(currentSessionId, effectiveAgent, providerID, modelID, variant) + if (targetSessionId && effectiveAgent) { + useSelectionStore.getState().saveSessionAgentSelection(targetSessionId, effectiveAgent) + useSelectionStore.getState().saveAgentModelVariantForSession(targetSessionId, effectiveAgent, providerID, modelID, variant) } - if (currentSessionId) { + if (targetSessionId) { const viewportState = useViewportStore.getState() - const memState = viewportState.sessionMemoryState.get(currentSessionId) + const memState = viewportState.sessionMemoryState.get(targetSessionId) if (!memState || !memState.lastUserMessageAt) { const newMemState = new Map(viewportState.sessionMemoryState) - newMemState.set(currentSessionId, { + newMemState.set(targetSessionId, { viewportAnchor: 0, isStreaming: false, lastAccessedAt: Date.now(), @@ -840,19 +856,19 @@ export const useSessionUIStore = create()((set, get) => ({ } } - const currentSessionDirectory = currentSessionId - ? normalizePath(get().getDirectoryForSession(currentSessionId)) + const currentSessionDirectory = targetSessionId + ? normalizePath(get().getDirectoryForSession(targetSessionId)) : null if (currentSessionDirectory) { await waitForWorktreeBootstrap(currentSessionDirectory) } - if (currentSessionId) { - notifyMessageSent(currentSessionId) + if (targetSessionId) { + notifyMessageSent(targetSessionId) } - if (currentSessionId) { - markPendingUserSendAnimation(currentSessionId) + if (targetSessionId) { + markPendingUserSendAnimation(targetSessionId) } const files = attachments?.map((a) => ({ @@ -863,7 +879,8 @@ export const useSessionUIStore = create()((set, get) => ({ })) await routeMessage({ - sessionId: currentSessionId || "", + sessionId: targetSessionId || "", + directory: currentSessionDirectory, content, providerID, modelID,