diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index 4163227b..12f5c143 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -1781,6 +1781,23 @@ const ChatInputComponent: React.FC = ({ 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[] = []; diff --git a/packages/ui/src/hooks/useSessionActivity.ts b/packages/ui/src/hooks/useSessionActivity.ts index 2c814193..53010e2f 100644 --- a/packages/ui/src/hooks/useSessionActivity.ts +++ b/packages/ui/src/hooks/useSessionActivity.ts @@ -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(() => { 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 { diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index de85748b..4c462bd5 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -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) => { 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) => { 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) + }) +}) diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index ce17b9cd..3055f336 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -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 { + 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 // ---------------------------------------------------------------------------