fix(chat): pin existing-session sends to captured target (#2424)
* fix(chat): pin sends to captured session * fix(chat): handle runtime cancellation consistently
This commit is contained in:
@@ -936,10 +936,18 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
setPrPickerOpen(true);
|
||||
}, []);
|
||||
|
||||
const getSubmitErrorMessage = (error: unknown, fallback: string) => {
|
||||
const message = error instanceof Error ? error.message : '';
|
||||
return message.toLowerCase().includes('runtime changed')
|
||||
? t('chat.chatInput.toast.messageSendFailed')
|
||||
: message || fallback;
|
||||
};
|
||||
|
||||
const handleSubmit = async (options?: SubmitOptions) => {
|
||||
const queuedOnly = options?.queuedOnly ?? false;
|
||||
const queuedMessageId = options?.queuedMessageId;
|
||||
const delivery = options?.delivery === 'steer' && sessionPhase !== 'idle' ? 'steer' : undefined;
|
||||
const capturedTarget = messageQueueTarget;
|
||||
const inputSnapshot = options?.presetText != null
|
||||
? {
|
||||
message: options.presetText,
|
||||
@@ -1012,7 +1020,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
}
|
||||
}
|
||||
|
||||
const sendMessageOptions = delivery ? { delivery } : undefined;
|
||||
const sendMessageOptions = capturedTarget
|
||||
? { target: capturedTarget, ...(delivery ? { delivery } : {}) }
|
||||
: delivery ? { delivery } : undefined;
|
||||
|
||||
// Inline review comments and synthetic context are consumed before
|
||||
// assembly so a failed send can restore exactly what it took.
|
||||
@@ -1058,10 +1068,10 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
if (outgoing.isEmpty) return;
|
||||
|
||||
// Clear queue and input
|
||||
if (messageQueueTarget && queuedMessageId) {
|
||||
removeFromQueue(messageQueueTarget, queuedMessageId);
|
||||
} else if (messageQueueTarget && hasQueuedMessages) {
|
||||
clearQueue(messageQueueTarget);
|
||||
if (capturedTarget && queuedMessageId) {
|
||||
removeFromQueue(capturedTarget, queuedMessageId);
|
||||
} else if (capturedTarget && hasQueuedMessages) {
|
||||
clearQueue(capturedTarget);
|
||||
}
|
||||
if (!queuedOnly) {
|
||||
setMessage('');
|
||||
@@ -1111,7 +1121,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
const compactDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory || undefined;
|
||||
await opencodeClient.summarizeSession(currentSessionId, currentProviderId, currentModelId, compactDirectory);
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t('chat.chatInput.toast.compactFailed'));
|
||||
toast.error(getSubmitErrorMessage(error, t('chat.chatInput.toast.compactFailed')));
|
||||
}
|
||||
return;
|
||||
}
|
||||
@@ -1143,15 +1153,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
);
|
||||
scrollToBottom?.();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : t(command.errorToastKey));
|
||||
toast.error(getSubmitErrorMessage(error, t(command.errorToastKey)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const currentSessionDirectory = currentSessionId
|
||||
? useSessionUIStore.getState().getDirectoryForSession(currentSessionId) || currentDirectory
|
||||
: currentDirectory;
|
||||
const currentSessionDirectory = capturedTarget?.directory ?? currentDirectory;
|
||||
const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false);
|
||||
if (shouldAddResponseStyle) {
|
||||
const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null);
|
||||
@@ -1258,6 +1266,14 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
|
||||
return;
|
||||
}
|
||||
|
||||
if (normalized.includes('runtime changed')) {
|
||||
if (allAttachments.length > 0) {
|
||||
useInputStore.getState().setAttachedFiles(allAttachments);
|
||||
}
|
||||
toast.error(t('chat.chatInput.toast.messageSendFailed'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (allAttachments.length > 0) {
|
||||
useInputStore.getState().setAttachedFiles(allAttachments);
|
||||
}
|
||||
|
||||
@@ -272,7 +272,11 @@ describe('buildQueuedAutoSendPayload', () => {
|
||||
]);
|
||||
|
||||
expect(payload).not.toBeNull();
|
||||
await sendQueuedAutoSendPayload('session-original', '/repo', payload!, {
|
||||
await sendQueuedAutoSendPayload({
|
||||
runtimeKey: 'runtime-original',
|
||||
sessionId: 'session-original',
|
||||
directory: '/repo',
|
||||
}, payload!, {
|
||||
providerID: 'provider-1',
|
||||
modelID: 'model-1',
|
||||
agent: 'agent-1',
|
||||
@@ -290,7 +294,13 @@ describe('buildQueuedAutoSendPayload', () => {
|
||||
undefined,
|
||||
'variant-1',
|
||||
'normal',
|
||||
{ sessionId: 'session-original', directory: '/repo' },
|
||||
{
|
||||
target: {
|
||||
runtimeKey: 'runtime-original',
|
||||
sessionId: 'session-original',
|
||||
directory: '/repo',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -103,8 +103,7 @@ type ResolvedQueuedSendConfig = {
|
||||
};
|
||||
|
||||
export const sendQueuedAutoSendPayload = (
|
||||
sessionId: string,
|
||||
directory: string,
|
||||
target: MessageQueueTarget,
|
||||
payload: QueuedAutoSendPayload,
|
||||
resolved: ResolvedQueuedSendConfig,
|
||||
) => {
|
||||
@@ -118,7 +117,7 @@ export const sendQueuedAutoSendPayload = (
|
||||
undefined,
|
||||
resolved.variant,
|
||||
'normal',
|
||||
{ sessionId, directory },
|
||||
{ target },
|
||||
);
|
||||
};
|
||||
|
||||
@@ -295,7 +294,7 @@ export function useQueuedMessageAutoSend(enabledOrOptions?: boolean | { enabled?
|
||||
useMessageQueueStore.getState().markSending(target, payload.queuedMessageId);
|
||||
|
||||
try {
|
||||
await sendQueuedAutoSendPayload(sessionId, target.directory, payload, {
|
||||
await sendQueuedAutoSendPayload(target, payload, {
|
||||
providerID: resolved.providerID,
|
||||
modelID: resolved.modelID,
|
||||
agent: resolved.agent,
|
||||
|
||||
@@ -6,6 +6,7 @@ type ConfigResponse = { data: Record<string, unknown> };
|
||||
|
||||
const configResolvers: Array<(response: ConfigResponse) => void> = [];
|
||||
let configCalls = 0;
|
||||
let runtimeKey = 'test-runtime';
|
||||
const promptAsyncCalls: unknown[][] = [];
|
||||
const promptAsyncResults: Array<unknown> = [];
|
||||
|
||||
@@ -44,7 +45,7 @@ mock.module('@/lib/runtime-url', () => ({
|
||||
|
||||
mock.module('@/lib/runtime-switch', () => ({
|
||||
getRuntimeApiBaseUrl: mock(() => ''),
|
||||
getRuntimeKey: mock(() => 'test-runtime'),
|
||||
getRuntimeKey: mock(() => runtimeKey),
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-fetch', () => ({
|
||||
@@ -60,6 +61,7 @@ mock.module('@/lib/startupTrace', () => ({
|
||||
const { opencodeClient } = await import(`./client?cache-test=${Date.now()}`);
|
||||
|
||||
beforeEach(() => {
|
||||
runtimeKey = 'test-runtime';
|
||||
promptAsyncCalls.length = 0;
|
||||
promptAsyncResults.length = 0;
|
||||
});
|
||||
@@ -160,4 +162,34 @@ describe('opencodeClient prompt retry behavior', () => {
|
||||
expect(promptAsyncCalls.length).toBe(1);
|
||||
expect(error instanceof Error ? error.message : String(error)).toContain('Failed to send message (503)');
|
||||
});
|
||||
|
||||
test('does not dispatch after the runtime changes while preparing attachments', async () => {
|
||||
runtimeKey = 'runtime-a';
|
||||
const pending = opencodeClient.sendMessage({
|
||||
id: 'ses_runtime_race',
|
||||
providerID: 'runtime-race-provider',
|
||||
modelID: 'model-a',
|
||||
text: 'hello',
|
||||
runtimeKey: 'runtime-a',
|
||||
files: [{
|
||||
type: 'file',
|
||||
mime: 'text/markdown',
|
||||
filename: 'notes.md',
|
||||
url: 'data:text/markdown,hello',
|
||||
}],
|
||||
});
|
||||
|
||||
runtimeKey = 'runtime-b';
|
||||
|
||||
let error: unknown = null;
|
||||
try {
|
||||
await pending;
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error instanceof Error ? error.message : String(error)).toContain('runtime changed');
|
||||
expect(promptAsyncCalls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -269,6 +269,12 @@ class OpencodeService {
|
||||
this.client = createRuntimeOpencodeClient({ baseUrl: this.baseUrl });
|
||||
}
|
||||
|
||||
private assertRuntimeUnchanged(runtimeKey?: string): void {
|
||||
if (runtimeKey && runtimeKey !== getRuntimeKey()) {
|
||||
throw new Error('Message was not sent because the runtime changed.');
|
||||
}
|
||||
}
|
||||
|
||||
getBaseUrl(): string {
|
||||
return this.baseUrl;
|
||||
}
|
||||
@@ -744,6 +750,7 @@ class OpencodeService {
|
||||
}
|
||||
|
||||
async sendMessage(params: {
|
||||
runtimeKey?: string;
|
||||
id: string;
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
@@ -769,6 +776,8 @@ class OpencodeService {
|
||||
};
|
||||
directory?: string | null;
|
||||
}): Promise<string> {
|
||||
this.assertRuntimeUnchanged(params.runtimeKey);
|
||||
|
||||
// Use the optimistic/client-generated ID as the real user message ID so SSE
|
||||
// can reconcile the echoed server message in-place.
|
||||
const messageId = params.messageId ?? ascendingId("msg");
|
||||
@@ -852,6 +861,7 @@ class OpencodeService {
|
||||
}
|
||||
|
||||
assertProviderCircuitClosed(params.providerID);
|
||||
this.assertRuntimeUnchanged(params.runtimeKey);
|
||||
|
||||
let response: Response;
|
||||
|
||||
@@ -918,6 +928,7 @@ class OpencodeService {
|
||||
}
|
||||
|
||||
async sendCommand(params: {
|
||||
runtimeKey?: string;
|
||||
id: string;
|
||||
providerID: string;
|
||||
modelID: string;
|
||||
@@ -929,6 +940,8 @@ class OpencodeService {
|
||||
messageId?: string;
|
||||
directory?: string | null;
|
||||
}): Promise<string> {
|
||||
this.assertRuntimeUnchanged(params.runtimeKey);
|
||||
|
||||
const tempMessageId = params.messageId ?? ascendingId("msg");
|
||||
|
||||
const parts: FilePartInput[] = [];
|
||||
@@ -939,6 +952,7 @@ class OpencodeService {
|
||||
}
|
||||
|
||||
const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory;
|
||||
this.assertRuntimeUnchanged(params.runtimeKey);
|
||||
|
||||
const response = await this.client.session.command({
|
||||
sessionID: params.id,
|
||||
@@ -968,6 +982,7 @@ class OpencodeService {
|
||||
}
|
||||
|
||||
async shellSession(params: {
|
||||
runtimeKey?: string;
|
||||
sessionId: string;
|
||||
command: string;
|
||||
agent: string;
|
||||
@@ -975,6 +990,7 @@ class OpencodeService {
|
||||
messageId?: string;
|
||||
directory?: string | null;
|
||||
}): Promise<{ info: Message; parts: Part[] }> {
|
||||
this.assertRuntimeUnchanged(params.runtimeKey);
|
||||
const requestDirectory = this.normalizeCandidatePath(params.directory ?? null) ?? this.currentDirectory;
|
||||
const response = await this.client.session.shell({
|
||||
sessionID: params.sessionId,
|
||||
|
||||
@@ -244,8 +244,9 @@ Rules:
|
||||
2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct.
|
||||
3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls.
|
||||
4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected.
|
||||
5. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
|
||||
6. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
|
||||
5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime.
|
||||
6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session.
|
||||
7. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message.
|
||||
|
||||
Examples of global-store updates performed in `session-actions.ts`:
|
||||
|
||||
|
||||
@@ -1027,7 +1027,7 @@ describe("optimisticSend target directory", () => {
|
||||
expect(targetStore.getState().part.msg_2).toEqual([revertedPart])
|
||||
})
|
||||
|
||||
test("allows callers to block final send when runtime changes after optimistic insert", async () => {
|
||||
test("rolls back a captured send when the runtime changes after optimistic insert", async () => {
|
||||
const targetStore = createStore({})
|
||||
const childStores = createChildStores([["/target/project", targetStore]])
|
||||
let optimisticAdd: OptimisticAddCall | null = null
|
||||
@@ -1052,15 +1052,15 @@ describe("optimisticSend target directory", () => {
|
||||
await optimisticSend({
|
||||
sessionId: "session-race",
|
||||
directory: "/target/project",
|
||||
runtimeKey: "runtime-a",
|
||||
content: "hello",
|
||||
providerID: "provider",
|
||||
modelID: "model",
|
||||
beforeOptimisticInsert: () => {
|
||||
onOptimisticInsert: () => {
|
||||
expect(getRuntimeKey()).toBe("runtime-a")
|
||||
switchRuntimeEndpoint({ apiBaseUrl: "http://runtime-b.test", runtimeKey: "runtime-b" })
|
||||
},
|
||||
send: async () => {
|
||||
switchRuntimeEndpoint({ apiBaseUrl: "http://runtime-b.test", runtimeKey: "runtime-b" })
|
||||
if (getRuntimeKey() !== "runtime-a") throw new Error("Auto-review stopped because the runtime changed.")
|
||||
finalSendCalled = true
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1257,6 +1257,7 @@ function ascendingId(prefix: string): string {
|
||||
* handles deduplication when the server echoes back the real message.
|
||||
*/
|
||||
export async function optimisticSend(input: {
|
||||
runtimeKey?: string
|
||||
sessionId: string
|
||||
content: string
|
||||
providerID: string
|
||||
@@ -1273,9 +1274,20 @@ export async function optimisticSend(input: {
|
||||
if (!_optimisticAdd || !_optimisticRemove) {
|
||||
throw new Error("Optimistic refs not set — is useSync() mounted?")
|
||||
}
|
||||
const optimisticAdd = _optimisticAdd
|
||||
const optimisticRemove = _optimisticRemove
|
||||
const optimisticConfirm = _optimisticConfirm
|
||||
|
||||
const assertRuntimeUnchanged = () => {
|
||||
if (input.runtimeKey && input.runtimeKey !== getRuntimeKey()) {
|
||||
throw new Error("Message was not sent because the runtime changed.")
|
||||
}
|
||||
}
|
||||
|
||||
assertRuntimeUnchanged()
|
||||
await waitForConnectionOrThrow()
|
||||
input.beforeOptimisticInsert?.()
|
||||
assertRuntimeUnchanged()
|
||||
|
||||
const targetDirectory = input.directory ?? dir()
|
||||
const store = targetDirectory ? dirStoreForDirectory(targetDirectory) : dirStore()
|
||||
@@ -1341,7 +1353,7 @@ export async function optimisticSend(input: {
|
||||
} as unknown as Message
|
||||
|
||||
// Insert into store + register in shadow Map (for mergeOptimisticPage cleanup)
|
||||
_optimisticAdd({
|
||||
optimisticAdd({
|
||||
sessionID: input.sessionId,
|
||||
directory: targetDirectory,
|
||||
message: optimisticMessage,
|
||||
@@ -1359,6 +1371,7 @@ export async function optimisticSend(input: {
|
||||
})
|
||||
|
||||
try {
|
||||
assertRuntimeUnchanged()
|
||||
await input.send(messageID)
|
||||
} catch (error) {
|
||||
const status = getErrorStatus(error)
|
||||
@@ -1369,7 +1382,7 @@ export async function optimisticSend(input: {
|
||||
|
||||
if (acceptedRecords) {
|
||||
materializeConfirmedSendRecords(store, input.sessionId, messageID, acceptedRecords)
|
||||
_optimisticConfirm?.({
|
||||
optimisticConfirm?.({
|
||||
sessionID: input.sessionId,
|
||||
directory: targetDirectory,
|
||||
messageID,
|
||||
@@ -1396,7 +1409,7 @@ export async function optimisticSend(input: {
|
||||
console.warn("[session-actions] prompt send rejected; rolling back optimistic message", failureRecord)
|
||||
|
||||
// Rollback via optimistic infrastructure
|
||||
_optimisticRemove({
|
||||
optimisticRemove({
|
||||
sessionID: input.sessionId,
|
||||
directory: targetDirectory,
|
||||
messageID,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { setActionRefs, setOptimisticRefs } from './session-actions';
|
||||
import { useSkillsStore } from '@/stores/useSkillsStore';
|
||||
import { useCommandsStore } from '@/stores/useCommandsStore';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
/**
|
||||
* Unit tests for session worktree routing through the authoritative store.
|
||||
@@ -228,6 +229,85 @@ describe('routeMessage directory scoping', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('sendMessage captured target', () => {
|
||||
let originalSendMessage;
|
||||
const calls = [];
|
||||
|
||||
beforeEach(() => {
|
||||
calls.length = 0;
|
||||
const childStore = {
|
||||
getState: () => ({ session: [], message: {}, part: {}, session_status: {} }),
|
||||
setState: () => {},
|
||||
};
|
||||
const childStores = {
|
||||
children: new Map(),
|
||||
ensureChild: () => childStore,
|
||||
getChild: () => childStore,
|
||||
};
|
||||
setActionRefs(opencodeClient, childStores, () => '/current/project');
|
||||
setOptimisticRefs(() => {}, () => {});
|
||||
useConfigStore.setState({ isConnected: true });
|
||||
useSessionUIStore.setState({
|
||||
currentSessionId: 'session-current',
|
||||
currentSessionDirectory: '/current/project',
|
||||
newSessionDraft: { open: false, directoryOverride: null, parentID: null },
|
||||
});
|
||||
|
||||
originalSendMessage = opencodeClient.sendMessage;
|
||||
opencodeClient.sendMessage = async (params) => {
|
||||
calls.push(params);
|
||||
return 'msg';
|
||||
};
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
opencodeClient.sendMessage = originalSendMessage;
|
||||
});
|
||||
|
||||
const sendToTarget = (target) => useSessionUIStore.getState().sendMessage(
|
||||
'queued message',
|
||||
'provider-a',
|
||||
'model-a',
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
'normal',
|
||||
{ target },
|
||||
);
|
||||
|
||||
test('uses the target captured before the active session changes', async () => {
|
||||
await sendToTarget({
|
||||
runtimeKey: getRuntimeKey(),
|
||||
sessionId: 'session-captured',
|
||||
directory: '/captured/project',
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].runtimeKey).toBe(getRuntimeKey());
|
||||
expect(calls[0].id).toBe('session-captured');
|
||||
expect(calls[0].directory).toBe('/captured/project');
|
||||
});
|
||||
|
||||
test('does not send a captured target through a different runtime', async () => {
|
||||
let error = null;
|
||||
try {
|
||||
await sendToTarget({
|
||||
runtimeKey: `${getRuntimeKey()}-stale`,
|
||||
sessionId: 'session-captured',
|
||||
directory: '/captured/project',
|
||||
});
|
||||
} catch (caught) {
|
||||
error = caught;
|
||||
}
|
||||
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect(error.message).toContain('runtime changed');
|
||||
expect(calls).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('slash-command goal objectives', () => {
|
||||
test('expands every $ARGUMENTS reference from the authoritative command template', () => {
|
||||
expect(expandSlashCommandGoalObjective('/issue--to-pr LIN-123 --draft', [{
|
||||
@@ -370,7 +450,12 @@ describe('routeMessage skill invocation', () => {
|
||||
|
||||
// Minimal optimistic + connection machinery so routeMessage can dispatch.
|
||||
const childStore = {
|
||||
getState: () => ({ session_status: {} }),
|
||||
getState: () => ({
|
||||
session: [],
|
||||
message: {},
|
||||
part: {},
|
||||
session_status: {},
|
||||
}),
|
||||
setState: () => {},
|
||||
};
|
||||
const childStores = {
|
||||
|
||||
@@ -122,6 +122,7 @@ export function expandSlashCommandGoalObjective(content: string, commands: GoalC
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function routeMessage(params: {
|
||||
runtimeKey?: string
|
||||
sessionId: string
|
||||
directory?: string | null
|
||||
content: string
|
||||
@@ -138,6 +139,7 @@ export function routeMessage(params: {
|
||||
const requestDirectory = params.directory ?? undefined
|
||||
if (params.inputMode === "shell") {
|
||||
return opencodeClient.shellSession({
|
||||
runtimeKey: params.runtimeKey,
|
||||
sessionId: params.sessionId,
|
||||
directory: requestDirectory,
|
||||
agent: params.agent ?? "",
|
||||
@@ -166,6 +168,7 @@ export function routeMessage(params: {
|
||||
|
||||
if (isCommand) {
|
||||
return optimisticSend({
|
||||
runtimeKey: params.runtimeKey,
|
||||
sessionId: params.sessionId,
|
||||
content: params.content,
|
||||
providerID: params.providerID,
|
||||
@@ -174,6 +177,7 @@ export function routeMessage(params: {
|
||||
directory: requestDirectory,
|
||||
files: params.files,
|
||||
send: (messageID) => opencodeClient.sendCommand({
|
||||
runtimeKey: params.runtimeKey,
|
||||
id: params.sessionId,
|
||||
providerID: params.providerID,
|
||||
modelID: params.modelID,
|
||||
@@ -191,6 +195,7 @@ export function routeMessage(params: {
|
||||
|
||||
// Normal prompt — optimistic insert so message appears instantly
|
||||
return optimisticSend({
|
||||
runtimeKey: params.runtimeKey,
|
||||
sessionId: params.sessionId,
|
||||
content: params.content,
|
||||
providerID: params.providerID,
|
||||
@@ -199,6 +204,7 @@ export function routeMessage(params: {
|
||||
directory: requestDirectory,
|
||||
files: params.files,
|
||||
send: (messageID) => opencodeClient.sendMessage({
|
||||
runtimeKey: params.runtimeKey,
|
||||
id: params.sessionId,
|
||||
providerID: params.providerID,
|
||||
modelID: params.modelID,
|
||||
@@ -215,7 +221,14 @@ export function routeMessage(params: {
|
||||
})
|
||||
}
|
||||
|
||||
type CapturedSendTarget = {
|
||||
runtimeKey: string
|
||||
sessionId: string
|
||||
directory: string
|
||||
}
|
||||
|
||||
type SendMessageOptions = {
|
||||
target?: CapturedSendTarget
|
||||
sessionId?: string
|
||||
directory?: string
|
||||
delivery?: 'steer'
|
||||
@@ -1198,8 +1211,13 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
inputMode?: "normal" | "shell",
|
||||
options?: SendMessageOptions,
|
||||
) => {
|
||||
const capturedTarget = options?.target
|
||||
if (capturedTarget && capturedTarget.runtimeKey !== getRuntimeKey()) {
|
||||
throw new Error("Message was not sent because the runtime changed.")
|
||||
}
|
||||
|
||||
// Clear non-Git changed-files bar on new user message for current session
|
||||
const sid = options?.sessionId ?? get().currentSessionId;
|
||||
const sid = capturedTarget?.sessionId ?? options?.sessionId ?? get().currentSessionId;
|
||||
if (sid) {
|
||||
const map = new Map(get().pendingChangesBarDismissed);
|
||||
map.delete(sid);
|
||||
@@ -1258,7 +1276,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
}
|
||||
|
||||
// ---- New session from draft ----
|
||||
if (!options?.sessionId && draft?.open) {
|
||||
if (!capturedTarget && !options?.sessionId && draft?.open) {
|
||||
const createdDraftSession = await materializeOpenDraftSession({
|
||||
providerID,
|
||||
modelID,
|
||||
@@ -1310,7 +1328,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
}
|
||||
|
||||
// ---- Existing session ----
|
||||
const targetSessionId = options?.sessionId ?? get().currentSessionId
|
||||
const targetSessionId = capturedTarget?.sessionId ?? options?.sessionId ?? get().currentSessionId
|
||||
const sessionAgentSelection = targetSessionId
|
||||
? useSelectionStore.getState().getSessionAgentSelection(targetSessionId)
|
||||
: null
|
||||
@@ -1345,7 +1363,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
}
|
||||
|
||||
const currentSessionDirectory = targetSessionId
|
||||
? normalizePath(options?.directory ?? get().getDirectoryForSession(targetSessionId))
|
||||
? normalizePath(capturedTarget?.directory ?? options?.directory ?? get().getDirectoryForSession(targetSessionId))
|
||||
: null
|
||||
if (targetSessionId) {
|
||||
notifyMessageSent(targetSessionId)
|
||||
@@ -1366,6 +1384,7 @@ export const useSessionUIStore = create<SessionUIState>()((set, get) => ({
|
||||
await applyArmedGoal(targetSessionId, currentSessionDirectory)
|
||||
}
|
||||
await routeMessage({
|
||||
runtimeKey: capturedTarget?.runtimeKey,
|
||||
sessionId: targetSessionId || "",
|
||||
directory: currentSessionDirectory,
|
||||
content,
|
||||
|
||||
Reference in New Issue
Block a user