fix: improve question handling in chat

Updates question card behavior
Improves localized question text
Covers session question actions with tests
This commit is contained in:
Bohdan Triapitsyn
2026-06-03 14:51:17 +03:00
parent a0b22c9960
commit 2163c2e3f7
12 changed files with 208 additions and 22 deletions
@@ -25,7 +25,7 @@ const SUMMARY_TAB = 'summary';
export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
const { t } = useI18n();
const respondToQuestion = sessionActions.respondToQuestion;
const rejectQuestion = sessionActions.rejectQuestion;;
const rejectQuestion = sessionActions.rejectQuestion;
const isMobile = useUIStore((state) => state.isMobile);
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
@@ -174,12 +174,19 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
const answers = buildAnswersPayload();
await respondToQuestion(question.sessionID, question.id, answers);
setHasResponded(true);
} catch {
// ignored
} catch (error) {
if (sessionActions.isQuestionRequestNotFoundError(error)) {
toast.info(t('chat.questionCard.noLongerPending'));
setHasResponded(true);
} else {
toast.error(t('chat.questionCard.submitFailed'), {
description: t('chat.questionCard.tryAgain'),
});
}
} finally {
setIsResponding(false);
}
}, [buildAnswersPayload, question.id, question.sessionID, requiredSatisfied, respondToQuestion]);
}, [buildAnswersPayload, question.id, question.sessionID, requiredSatisfied, respondToQuestion, t]);
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
@@ -202,12 +209,19 @@ export const QuestionCard: React.FC<QuestionCardProps> = ({ question }) => {
try {
await rejectQuestion(question.sessionID, question.id);
setHasResponded(true);
} catch {
// ignored
} catch (error) {
if (sessionActions.isQuestionRequestNotFoundError(error)) {
toast.info(t('chat.questionCard.noLongerPending'));
setHasResponded(true);
} else {
toast.error(t('chat.questionCard.dismissFailed'), {
description: t('chat.questionCard.tryAgain'),
});
}
} finally {
setIsResponding(false);
}
}, [question.id, question.sessionID, rejectQuestion]);
}, [question.id, question.sessionID, rejectQuestion, t]);
const handleCopyMarkdown = React.useCallback(async () => {
const text = serializeQuestionAsMarkdown(question);
+4
View File
@@ -1685,6 +1685,10 @@ export const dict = {
'chat.questionCard.copiedMarkdown': 'Question copied as Markdown',
'chat.questionCard.copiedJson': 'Question copied as JSON',
'chat.questionCard.copyFailed': 'Failed to copy question',
'chat.questionCard.submitFailed': 'Failed to send response',
'chat.questionCard.dismissFailed': 'Failed to dismiss question',
'chat.questionCard.noLongerPending': 'This question is no longer waiting for a response.',
'chat.questionCard.tryAgain': 'Please try again in a moment.',
'chat.textSelection.toast.noProject': 'No project found for this session',
'chat.textSelection.toast.addToNotesFailed': 'Failed to add to notes',
'chat.textSelection.toast.addToNotesSuccess': 'Added selected text to notes',
+4
View File
@@ -1651,6 +1651,10 @@ export const dict: Record<I18nKey, string> = {
"chat.questionCard.copiedMarkdown": "Pregunta copiada como Markdown",
"chat.questionCard.copiedJson": "Pregunta copiada como JSON",
"chat.questionCard.copyFailed": "No se pudo copiar la pregunta",
"chat.questionCard.submitFailed": "No se pudo enviar la respuesta",
"chat.questionCard.dismissFailed": "No se pudo descartar la pregunta",
"chat.questionCard.noLongerPending": "Esta pregunta ya no espera una respuesta.",
"chat.questionCard.tryAgain": "Inténtalo de nuevo en un momento.",
"chat.textSelection.toast.noProject": "No se encontró proyecto para esta sesión",
"chat.textSelection.toast.addToNotesFailed": "No se pudo añadir a las notas",
"chat.textSelection.toast.addToNotesSuccess": "Texto seleccionado añadido a notas",
+4
View File
@@ -1687,6 +1687,10 @@ export const dict: Record<I18nKey, string> = {
'chat.questionCard.copiedMarkdown': '질문을 Markdown으로 복사했습니다',
'chat.questionCard.copiedJson': '질문을 JSON으로 복사했습니다',
'chat.questionCard.copyFailed': '질문 복사에 실패했습니다',
'chat.questionCard.submitFailed': '응답 전송에 실패했습니다',
'chat.questionCard.dismissFailed': '질문 닫기에 실패했습니다',
'chat.questionCard.noLongerPending': '이 질문은 더 이상 응답을 기다리지 않습니다.',
'chat.questionCard.tryAgain': '잠시 후 다시 시도하세요.',
'chat.textSelection.toast.noProject': '이 세션의 프로젝트를 찾을 수 없음',
'chat.textSelection.toast.addToNotesFailed': '메모 추가 실패',
'chat.textSelection.toast.addToNotesSuccess': '선택한 텍스트를 메모에 추가함',
+4
View File
@@ -669,6 +669,10 @@ export const dict: Record<I18nKey, string> = {
'chat.questionCard.copiedMarkdown': 'Pytanie skopiowane jako Markdown',
'chat.questionCard.copiedJson': 'Pytanie skopiowane jako JSON',
'chat.questionCard.copyFailed': 'Nie udało się skopiować pytania',
'chat.questionCard.submitFailed': 'Nie udało się wysłać odpowiedzi',
'chat.questionCard.dismissFailed': 'Nie udało się odrzucić pytania',
'chat.questionCard.noLongerPending': 'To pytanie nie czeka już na odpowiedź.',
'chat.questionCard.tryAgain': 'Spróbuj ponownie za chwilę.',
'chat.textSelection.toast.noProject': 'Nie znaleziono projektu dla tej sesji',
'chat.textSelection.toast.addToNotesFailed': 'Nie udało się dodać do notatek',
'chat.textSelection.toast.addToNotesSuccess': 'Dodano zaznaczony tekst do notatek',
@@ -1651,6 +1651,10 @@ export const dict: Record<I18nKey, string> = {
"chat.questionCard.copiedMarkdown": "Pergunta copiada como Markdown",
"chat.questionCard.copiedJson": "Pergunta copiada como JSON",
"chat.questionCard.copyFailed": "Falha ao copiar a pergunta",
"chat.questionCard.submitFailed": "Falha ao enviar a resposta",
"chat.questionCard.dismissFailed": "Falha ao descartar a pergunta",
"chat.questionCard.noLongerPending": "Esta pergunta não está mais aguardando uma resposta.",
"chat.questionCard.tryAgain": "Tente novamente em instantes.",
"chat.textSelection.toast.noProject": "Não foi encontrado projeto para esta sessão",
"chat.textSelection.toast.addToNotesFailed": "Não foi possível adicionar às notas",
"chat.textSelection.toast.addToNotesSuccess": "Texto selecionado adicionado às notas",
+4
View File
@@ -1651,6 +1651,10 @@ export const dict: Record<I18nKey, string> = {
"chat.questionCard.copiedMarkdown": "Питання скопійовано як Markdown",
"chat.questionCard.copiedJson": "Питання скопійовано як JSON",
"chat.questionCard.copyFailed": "Не вдалося скопіювати питання",
"chat.questionCard.submitFailed": "Не вдалося надіслати відповідь",
"chat.questionCard.dismissFailed": "Не вдалося відхилити питання",
"chat.questionCard.noLongerPending": "Це питання більше не очікує відповіді.",
"chat.questionCard.tryAgain": "Спробуйте ще раз за мить.",
"chat.textSelection.toast.noProject": "Для цієї сесії не знайдено жодного проєкту",
"chat.textSelection.toast.addToNotesFailed": "Не вдалося додати до нотаток",
"chat.textSelection.toast.addToNotesSuccess": "Вибраний текст додано до нотаток",
@@ -1651,6 +1651,10 @@ export const dict: Record<I18nKey, string> = {
'chat.questionCard.copiedMarkdown': '问题已复制为 Markdown',
'chat.questionCard.copiedJson': '问题已复制为 JSON',
'chat.questionCard.copyFailed': '复制问题失败',
'chat.questionCard.submitFailed': '发送回答失败',
'chat.questionCard.dismissFailed': '忽略问题失败',
'chat.questionCard.noLongerPending': '此问题不再等待回答。',
'chat.questionCard.tryAgain': '请稍后重试。',
'chat.textSelection.toast.noProject': '未找到此会话对应的项目',
'chat.textSelection.toast.addToNotesFailed': '添加到笔记失败',
'chat.textSelection.toast.addToNotesSuccess': '已将选中文本添加到笔记',
@@ -1655,6 +1655,10 @@ export const dict: Record<I18nKey, string> = {
'chat.questionCard.copiedMarkdown': '問題已複製為 Markdown',
'chat.questionCard.copiedJson': '問題已複製為 JSON',
'chat.questionCard.copyFailed': '複製問題失敗',
'chat.questionCard.submitFailed': '傳送回答失敗',
'chat.questionCard.dismissFailed': '忽略問題失敗',
'chat.questionCard.noLongerPending': '此問題不再等待回答。',
'chat.questionCard.tryAgain': '請稍後再試。',
'chat.textSelection.toast.noProject': '找不到此會話對應的專案',
'chat.textSelection.toast.addToNotesFailed': '加入筆記失敗',
'chat.textSelection.toast.addToNotesSuccess': '已將選取文字加入筆記',
+6 -2
View File
@@ -57,7 +57,9 @@ type SdkResult<T> = {
function unwrapSdkData<T>(result: SdkResult<T>, operation: string): T {
if (result.error) {
const status = result.response?.status;
throw new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`);
const error = new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`) as Error & { status?: number };
if (status !== undefined) error.status = status;
throw error;
}
if (result.data === undefined || result.data === null) {
throw new Error(`${operation} failed: empty response`);
@@ -68,7 +70,9 @@ function unwrapSdkData<T>(result: SdkResult<T>, operation: string): T {
function unwrapSdkOptional<T>(result: SdkResult<T>, operation: string): T | undefined {
if (result.error) {
const status = result.response?.status;
throw new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`);
const error = new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`) as Error & { status?: number };
if (status !== undefined) error.status = status;
throw error;
}
return result.data;
}
+52 -2
View File
@@ -1,9 +1,12 @@
import { describe, expect, test, beforeEach, mock } from "bun:test"
import type { PermissionRequest } from "@/types/permission"
import type { QuestionRequest } from "@/types/question"
// Mock SDK client that records permission.reply / question.reply calls
const replyCalls: Array<{ method: string; params: Record<string, unknown> }> = []
const scopedClientDirectories: string[] = []
let sessionRevertResult: { data?: unknown; error?: unknown; response?: { status?: number } } = {}
let questionReplyError: unknown | null = null
const mockScopedClient = {
permission: {
@@ -15,6 +18,9 @@ const mockScopedClient = {
question: {
reply: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "question.reply", params })
if (questionReplyError) {
return Promise.resolve({ error: questionReplyError, response: { status: 404 } })
}
return Promise.resolve({ data: true })
}),
reject: mock((params: Record<string, unknown>) => {
@@ -48,6 +54,9 @@ const mockSdk = {
question: {
reply: mock((params: Record<string, unknown>) => {
replyCalls.push({ method: "question.reply", params })
if (questionReplyError) {
return Promise.resolve({ error: questionReplyError, response: { status: 404 } })
}
return Promise.resolve({ data: true })
}),
reject: mock((params: Record<string, unknown>) => {
@@ -60,8 +69,10 @@ const mockSdk = {
// Mock opencodeClient singleton
mock.module("@/lib/opencode/client", () => ({
opencodeClient: {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
getScopedSdkClient: (_: string) => mockScopedClient,
getScopedSdkClient: (directory: string) => {
scopedClientDirectories.push(directory)
return mockScopedClient
},
getDirectory: () => "/test/project",
replyToPermission: mock((requestId: string, reply: string, options?: { directory?: string | null }) => {
replyCalls.push({ method: "permission.reply", params: { requestID: requestId, reply, directory: options?.directory } })
@@ -170,6 +181,7 @@ function createChildStores(entries: Array<[string, StoreApi<DirectoryStore>]>) {
describe("respondToPermission passes directory", () => {
beforeEach(() => {
replyCalls.length = 0
scopedClientDirectories.length = 0
sessionRevertResult = {}
})
@@ -229,6 +241,7 @@ describe("respondToPermission passes directory", () => {
describe("revertToMessage passes session directory", () => {
beforeEach(() => {
replyCalls.length = 0
scopedClientDirectories.length = 0
sessionRevertResult = {}
Object.assign(inputState, {
pendingInputText: "previous draft",
@@ -296,6 +309,8 @@ describe("revertToMessage passes session directory", () => {
describe("dismissPermission passes directory", () => {
beforeEach(() => {
replyCalls.length = 0
scopedClientDirectories.length = 0
questionReplyError = null
})
test("passes directory and reply=reject", async () => {
@@ -326,6 +341,8 @@ describe("dismissPermission passes directory", () => {
describe("respondToQuestion passes directory", () => {
beforeEach(() => {
replyCalls.length = 0
scopedClientDirectories.length = 0
questionReplyError = null
})
test("passes directory to question.reply", async () => {
@@ -339,12 +356,45 @@ describe("respondToQuestion passes directory", () => {
expect(replyCalls.length).toBe(1)
expect(replyCalls[0].params.requestID).toBe("q-1")
expect(replyCalls[0].params.directory).toBe("/test/project")
expect(scopedClientDirectories).toEqual(["/test/project"])
})
test("removes stale question from child store when reply returns not found", async () => {
const question: QuestionRequest = {
id: "q-stale",
sessionID: "session-a",
questions: [
{
question: "Choose an option",
header: "Choice",
options: [{ label: "Yes", description: "Proceed" }],
},
],
}
const store = createStore({}, { question: { "session-a": [question] } })
const childStores = createChildStores([["/test/project", store]])
questionReplyError = Object.assign(new Error("question.reply failed (404): QuestionNotFoundError"), { status: 404 })
const { setActionRefs, respondToQuestion } = await import("./session-actions")
setActionRefs(mockSdk as unknown as OpencodeClient, childStores, () => "/test/project")
let thrown: unknown
try {
await respondToQuestion("session-a", "q-stale", [["Yes"]])
} catch (error) {
thrown = error
}
expect(thrown).toBeInstanceOf(Error)
expect(store.getState().question["session-a"]).toBe(undefined)
})
})
describe("rejectQuestion passes directory", () => {
beforeEach(() => {
replyCalls.length = 0
scopedClientDirectories.length = 0
questionReplyError = null
})
test("passes directory to question.reject", async () => {
+97 -11
View File
@@ -60,7 +60,9 @@ function formatSdkError(error: unknown): string {
function assertSdkSuccess<T>(result: SdkResult<T>, operation: string): T | undefined {
if (!result.error) return result.data
const status = result.response?.status
throw new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`)
const error = new Error(`${operation} failed${status ? ` (${status})` : ""}: ${formatSdkError(result.error)}`) as Error & { status?: number }
if (status !== undefined) error.status = status
throw error
}
function assertSdkData<T>(result: SdkResult<T>, operation: string): T {
@@ -256,6 +258,56 @@ function resolveDirectoryForBlockingRequest(
return null
}
export function isQuestionRequestNotFoundError(error: unknown): boolean {
if (error && typeof error === "object") {
const status = (error as { status?: unknown }).status
if (status === 404) return true
}
let message = ""
if (error instanceof Error) {
message = error.message
} else if (typeof error === "string") {
message = error
}
return /Question(?:\.)?NotFoundError|Question request not found/i.test(message)
}
function removeQuestionRequestFromChildStores(sessionId: string, requestId: string): boolean {
const stores = _childStores
if (!stores || !requestId) return false
let removed = false
for (const [, store] of stores.children) {
const current = store.getState().question ?? {}
let nextQuestion: typeof current | null = null
const sessionIds = new Set([sessionId, ...Object.keys(current)].filter(Boolean))
for (const candidateSessionId of sessionIds) {
const requests = current[candidateSessionId]
if (!requests?.length) continue
const nextRequests = requests.filter((request) => request.id !== requestId)
if (nextRequests.length === requests.length) continue
nextQuestion ??= { ...current }
if (nextRequests.length > 0) {
nextQuestion[candidateSessionId] = nextRequests
} else {
delete nextQuestion[candidateSessionId]
}
removed = true
}
if (nextQuestion) {
store.setState({ question: nextQuestion })
}
}
return removed
}
function getRequestReplyClient(
type: "permission" | "question",
sessionId: string,
@@ -584,7 +636,12 @@ export async function respondToPermission(
const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
if (await opencodeClient.replyToPermission(requestId, response, { directory }) !== true) {
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
requestID: requestId,
reply: response,
...(directory ? { directory } : {}),
})
if (assertSdkData(result, "permission.reply") !== true) {
throw new Error("Permission reply failed")
}
}
@@ -597,7 +654,12 @@ export async function dismissPermission(
const directory = resolveDirectoryForBlockingRequest("permission", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
if (await opencodeClient.replyToPermission(requestId, "reject", { directory }) !== true) {
const result = await getRequestReplyClient("permission", sessionId, requestId).permission.reply({
requestID: requestId,
reply: "reject",
...(directory ? { directory } : {}),
})
if (assertSdkData(result, "permission.reply") !== true) {
throw new Error("Permission dismissal failed")
}
}
@@ -615,8 +677,25 @@ export async function respondToQuestion(
const directory = resolveDirectoryForBlockingRequest("question", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
if (await opencodeClient.replyToQuestion(requestId, answers, directory) !== true) {
throw new Error("Question reply failed")
try {
const normalizedAnswers = answers.length === 0
? []
: Array.isArray(answers[0])
? answers as string[][]
: [answers as string[]]
const result = await getRequestReplyClient("question", sessionId, requestId).question.reply({
requestID: requestId,
answers: normalizedAnswers,
...(directory ? { directory } : {}),
})
if (assertSdkData(result, "question.reply") !== true) {
throw new Error("Question reply failed")
}
} catch (error) {
if (isQuestionRequestNotFoundError(error)) {
removeQuestionRequestFromChildStores(sessionId, requestId)
}
throw error
}
}
@@ -628,12 +707,19 @@ export async function rejectQuestion(
const directory = resolveDirectoryForBlockingRequest("question", sessionId, requestId)
|| getSessionDirectory(sessionId)
|| dir()
const result = await getRequestReplyClient("question", sessionId, requestId).question.reject({
requestID: requestId,
...(directory ? { directory } : {}),
})
if (assertSdkData(result, "question.reject") !== true) {
throw new Error("Question rejection failed")
try {
const result = await getRequestReplyClient("question", sessionId, requestId).question.reject({
requestID: requestId,
...(directory ? { directory } : {}),
})
if (assertSdkData(result, "question.reject") !== true) {
throw new Error("Question rejection failed")
}
} catch (error) {
if (isQuestionRequestNotFoundError(error)) {
removeQuestionRequestFromChildStores(sessionId, requestId)
}
throw error
}
}