diff --git a/packages/ui/src/components/chat/QuestionCard.tsx b/packages/ui/src/components/chat/QuestionCard.tsx index 38dbeab5..93b13e77 100644 --- a/packages/ui/src/components/chat/QuestionCard.tsx +++ b/packages/ui/src/components/chat/QuestionCard.tsx @@ -25,7 +25,7 @@ const SUMMARY_TAB = 'summary'; export const QuestionCard: React.FC = ({ 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 = ({ 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) => { @@ -202,12 +209,19 @@ export const QuestionCard: React.FC = ({ 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); diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index a5fc26c8..3d3101fc 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -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', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index a36e9052..2e6b4781 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1651,6 +1651,10 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 9feaa279..9c3016b6 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1687,6 +1687,10 @@ export const dict: Record = { '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': '선택한 텍스트를 메모에 추가함', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index da64caa1..f670de4c 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -669,6 +669,10 @@ export const dict: Record = { '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', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index abc6faee..c967faaa 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1651,6 +1651,10 @@ export const dict: Record = { "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", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 6e605d6b..c7354203 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1651,6 +1651,10 @@ export const dict: Record = { "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": "Вибраний текст додано до нотаток", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 549ebdc4..c524d06d 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1651,6 +1651,10 @@ export const dict: Record = { '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': '已将选中文本添加到笔记', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 6a0b9d35..c7301384 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1655,6 +1655,10 @@ export const dict: Record = { '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': '已將選取文字加入筆記', diff --git a/packages/ui/src/lib/opencode/client.ts b/packages/ui/src/lib/opencode/client.ts index 5f159097..81b7c499 100644 --- a/packages/ui/src/lib/opencode/client.ts +++ b/packages/ui/src/lib/opencode/client.ts @@ -57,7 +57,9 @@ type SdkResult = { function unwrapSdkData(result: SdkResult, 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(result: SdkResult, operation: string): T { function unwrapSdkOptional(result: SdkResult, 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; } diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index 709980e1..d8d78b29 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -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 }> = [] +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) => { 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) => { @@ -48,6 +54,9 @@ const mockSdk = { question: { reply: mock((params: Record) => { 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) => { @@ -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]>) { 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 () => { diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index 59e5042b..786d1deb 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -60,7 +60,9 @@ function formatSdkError(error: unknown): string { function assertSdkSuccess(result: SdkResult, 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(result: SdkResult, 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 } }