fix(chat-input): dismiss open question prompt when sending a message (#1740)

Sending a message while a question prompt was open left the prompt
lingering, blocked the send, or collided with the still-blocked agent
turn. Two root causes:

useSessionActivity treated pending permissions as idle but not pending
questions, so the send button became Stop during a question and Enter
queued/collided instead of sending. handleSubmit also never dismissed
the open question, stranding the session in a half-answered state.

The send path now dismisses open questions for the session subtree
(optimistic local clear so the card vanishes instantly, plus a formal
question.reject) and queues the message. The queued-message auto-send
hook then delivers it as the next turn once the rejected turn winds
down and the session returns to idle. Queueing avoids aborting the
turn, which surfaced an unwanted "running turn was stopped" notice.

Regression tests cover the no-op, subtree dismissal (root + subagent
child), and QuestionNotFoundError paths.
This commit is contained in:
Tom Rochette
2026-06-25 10:55:35 +03:00
committed by GitHub
parent 4a068fca86
commit 9a2012c94e
4 changed files with 188 additions and 5 deletions
@@ -1781,6 +1781,23 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
return;
}
// Sending is authoritative: if a question prompt is open, dismiss it
// so the prompt cannot linger or strand the session. The dismiss clears
// the card instantly (optimistic) and formally rejects the question.
// Rejecting unblocks the agent's tool but does NOT end its turn, so a
// direct send would race with the still-active run and be silently
// discarded by the OpenCode runner. Instead we queue the message; the
// queued-message auto-send hook delivers it as the next turn once the
// rejected turn winds down and the session returns to idle. This avoids
// aborting the turn (which would surface an "aborted" notice).
if (currentSessionId && !queuedOnly) {
const dismissedQuestions = await sessionActions.dismissOpenQuestionsForSession(currentSessionId);
if (dismissedQuestions) {
handleQueueMessage();
return;
}
}
// Build the primary message (first part) and additional parts
let primaryText = '';
let primaryAttachments: AttachedFile[] = [];
+9 -5
View File
@@ -1,6 +1,6 @@
import React from 'react';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionStatus, useSessionMessages, useSessionPermissions } from '@/sync/sync-context';
import { useSessionStatus, useSessionMessages, useSessionPermissions, useSessionQuestions } from '@/sync/sync-context';
// Mirrors OpenCode SessionStatus: busy|retry|idle.
export type SessionActivityPhase = 'idle' | 'busy' | 'retry';
@@ -23,18 +23,22 @@ const IDLE_RESULT: SessionActivityResult = {
* Determines if a session is actively working.
* Checks session_status and, only when status is missing, falls back to the
* trailing assistant message when its completion update has not landed yet.
* Returns idle when permissions are pending (permission indicator takes priority).
* Returns idle when permissions or questions are pending (the permission /
* question indicator takes priority, and the send button must stay available so
* the user can supersede the prompt with a new message).
*/
export function useSessionActivity(sessionId: string | null | undefined, directory?: string): SessionActivityResult {
const status = useSessionStatus(sessionId ?? '', directory);
const messages = useSessionMessages(sessionId ?? '', directory);
const permissions = useSessionPermissions(sessionId ?? '', directory);
const questions = useSessionQuestions(sessionId ?? '', directory);
return React.useMemo<SessionActivityResult>(() => {
if (!sessionId) return IDLE_RESULT;
// Permissions pending → idle (permission indicator takes priority)
if (permissions.length > 0) return IDLE_RESULT;
// Permissions or questions pending → idle (the blocking indicator takes
// priority and the send button must remain a send, not a stop).
if (permissions.length > 0 || questions.length > 0) return IDLE_RESULT;
const phase: SessionActivityPhase = (status?.type ?? 'idle') as SessionActivityPhase;
@@ -61,7 +65,7 @@ export function useSessionActivity(sessionId: string | null | undefined, directo
isBusy: phase === 'busy' || (!statusWorking && hasPendingAssistant),
isCooldown: false,
};
}, [sessionId, status, messages, permissions]);
}, [sessionId, status, messages, permissions, questions]);
}
export function useCurrentSessionActivity(): SessionActivityResult {
@@ -8,6 +8,7 @@ const scopedClientDirectories: string[] = []
const registeredSessionDirectories: Array<{ sessionID: string; directory: string }> = []
let sessionRevertResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
let questionReplyError: unknown | null = null
let questionRejectError: unknown | null = null
let sessionShareResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
const globalUpsertedSessions: unknown[] = []
@@ -28,6 +29,9 @@ const mockScopedClient = {
}),
reject: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "question.reject", params })
if (questionRejectError) {
return Promise.resolve({ error: questionRejectError, response: { status: 404 } })
}
return Promise.resolve({ data: true })
}),
},
@@ -72,6 +76,9 @@ const mockSdk = {
}),
reject: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "question.reject", params })
if (questionRejectError) {
return Promise.resolve({ error: questionRejectError, response: { status: 404 } })
}
return Promise.resolve({ data: true })
}),
},
@@ -599,3 +606,91 @@ describe("rejectQuestion passes directory", () => {
expect(replyCalls[0].params.directory).toBe("/test/project")
})
})
function buildQuestion(id: string, sessionId: string): QuestionRequest {
return {
id,
sessionID: sessionId,
questions: [
{
question: "Choose an option",
header: "Choice",
options: [{ label: "Yes", description: "Proceed" }],
},
],
}
}
describe("dismissOpenQuestionsForSession", () => {
beforeEach(() => {
replyCalls.length = 0
scopedClientDirectories.length = 0
questionReplyError = null
})
test("returns false and rejects nothing when no questions are pending", async () => {
const store = createStore({}, { session: [{ id: "session-a", time: { created: 1 } } as Session] })
const childStores = createChildStores([["/test/project", store]])
const { setActionRefs, dismissOpenQuestionsForSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
const dismissed = await dismissOpenQuestionsForSession("session-a")
expect(dismissed).toBe(false)
expect(replyCalls.filter((call) => call.method === "question.reject")).toHaveLength(0)
})
test("rejects every pending question in the session subtree (root + subagent child)", async () => {
const rootQuestion = buildQuestion("q-root", "session-a")
const childQuestion = buildQuestion("q-child", "session-child")
const store = createStore({}, {
session: [
{ id: "session-a", time: { created: 1 } } as Session,
{ id: "session-child", parentID: "session-a", time: { created: 2 } } as Session,
],
question: {
"session-a": [rootQuestion],
"session-child": [childQuestion],
},
})
const childStores = createChildStores([["/test/project", store]])
const { setActionRefs, dismissOpenQuestionsForSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
const dismissed = await dismissOpenQuestionsForSession("session-a")
expect(dismissed).toBe(true)
const rejectCalls = replyCalls.filter((call) => call.method === "question.reject")
expect(rejectCalls).toHaveLength(2)
const rejectedIds = rejectCalls.map((call) => call.params.requestID).sort()
expect(rejectedIds).toEqual(["q-child", "q-root"])
// Optimistic clear: the questions are removed from the local store so the
// prompt disappears instantly, without waiting for the reject round-trip.
expect(store.getState().question["session-a"]).toBe(undefined)
expect(store.getState().question["session-child"]).toBe(undefined)
})
test("swallows QuestionNotFoundError so a stranded question never blocks the send", async () => {
const staleQuestion = buildQuestion("q-stale", "session-a")
const store = createStore({}, {
session: [{ id: "session-a", time: { created: 1 } } as Session],
question: { "session-a": [staleQuestion] },
})
const childStores = createChildStores([["/test/project", store]])
questionRejectError = Object.assign(new Error("question.reject failed (404): QuestionNotFoundError"), { status: 404 })
const { setActionRefs, dismissOpenQuestionsForSession } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
const dismissed = await dismissOpenQuestionsForSession("session-a")
expect(dismissed).toBe(true)
const rejectCalls = replyCalls.filter((call) => call.method === "question.reject")
expect(rejectCalls).toHaveLength(1)
expect(rejectCalls[0].params.requestID).toBe("q-stale")
// The stale entry is cleared from the store even though the server reported not-found.
expect(store.getState().question["session-a"]).toBe(undefined)
})
})
+67
View File
@@ -8,6 +8,7 @@ import { Binary } from "./binary"
import { useSessionUIStore } from "./session-ui-store"
import { useInputStore } from "./input-store"
import type { ChildStoreManager } from "./child-store"
import { computeSubtreeIds } from "./scoped-blocking-requests"
import { opencodeClient } from "@/lib/opencode/client"
import { mergeSessionDirectoryMetadata, useGlobalSessionsStore } from "@/stores/useGlobalSessionsStore"
import { useConfigStore } from "@/stores/useConfigStore"
@@ -815,6 +816,72 @@ export async function rejectQuestion(
}
}
/**
* Dismiss every pending question for the session subtree rooted at `sessionId`
* (the session itself plus any subagent children). Used by the chat send path:
* sending a message while a question prompt is open must cancel/supersede the
* open question so it cannot linger or strand the session in a half-answered
* state.
*
* The questions are removed from the local store OPTIMISTICALLY (before any
* network call) so the prompt disappears instantly instead of waiting on the
* `question.reject` round-trip. Each question is then formally rejected on the
* backend, which fires `question.rejected` for reconciliation.
*
* Returns true when at least one question was dismissed. Rejection failures are
* swallowed (a stranded question must never block the send);
* QuestionNotFoundError also clears the stale entry from the child store via
* {@link rejectQuestion}.
*
* NOTE: rejecting unblocks the agent's tool but does NOT end its turn. Callers
* that need to send the next message right away (the chat send path) must also
* abort the session so the OpenCode runner reaches `idle` otherwise the new
* prompt arrives while the run is still active and is discarded by the runner's
* `ensureRunning`.
*/
export async function dismissOpenQuestionsForSession(sessionId: string): Promise<boolean> {
if (!sessionId) return false
const stores = _childStores
if (!stores) return false
const toDismiss: Array<{ sessionId: string; requestId: string }> = []
for (const [, store] of stores.children) {
const state = store.getState()
const scopedIds = computeSubtreeIds(state.session, sessionId)
if (scopedIds.size === 0) continue
const questionsBySession = state.question ?? {}
for (const scopedId of scopedIds) {
const requests = questionsBySession[scopedId]
if (!requests) continue
for (const request of requests) {
toDismiss.push({ sessionId: scopedId, requestId: request.id })
}
}
}
if (toDismiss.length === 0) return false
// Optimistically clear the questions from the local store so the prompt
// disappears immediately, before the reject round-trip.
for (const { sessionId: scopedSessionId, requestId } of toDismiss) {
removeQuestionRequestFromChildStores(scopedSessionId, requestId)
}
await Promise.all(
toDismiss.map(async ({ sessionId: scopedSessionId, requestId }) => {
try {
await rejectQuestion(scopedSessionId, requestId)
} catch (error) {
if (isQuestionRequestNotFoundError(error)) return
// Swallow: a failed dismissal must not block the send. The next
// question.asked / question.rejected event reconciles the store.
console.error("[session-actions] Failed to dismiss open question on send:", error)
}
}),
)
return true
}
// ---------------------------------------------------------------------------
// Message history
// ---------------------------------------------------------------------------