From 9fcbb563306401525e217aae6643370d613612d1 Mon Sep 17 00:00:00 2001 From: engineer Date: Sat, 14 Feb 2026 16:37:09 -0800 Subject: [PATCH 1/2] fix: make question widget options clickable inline with test coverage (#79) - Transform inline question options from non-clickable to interactive + ) + })} )} ))} + {submitError && ( +
{submitError}
+ )} + {canAnswer && ( +
+ + +
+ )} ) } diff --git a/frontend/src/components/question/QuestionDialog.test.tsx b/frontend/src/components/question/QuestionDialog.test.tsx new file mode 100644 index 00000000..aad6ab5b --- /dev/null +++ b/frontend/src/components/question/QuestionDialog.test.tsx @@ -0,0 +1,199 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { QuestionDialog } from './QuestionDialog' + +const mockRespondToQuestion = vi.fn() +const mockRejectQuestion = vi.fn() +const mockDismissDialog = vi.fn() + +vi.mock('@/contexts/QuestionContext', () => ({ + useQuestionContext: vi.fn(() => mockContextValue), +})) + +vi.mock('@/components/ui/button', () => ({ + Button: ({ children, onClick, disabled, variant, ...props }: { children: React.ReactNode; onClick?: () => void; disabled?: boolean; variant?: string; [key: string]: unknown }) => ( + + ), +})) + +vi.mock('@/components/ui/checkbox', () => ({ + Checkbox: ({ checked, onCheckedChange, ...props }: { checked?: boolean; onCheckedChange?: () => void; [key: string]: unknown }) => ( + + ), +})) + +vi.mock('@/components/ui/input', () => ({ + Input: ({ value, onChange, placeholder, ...props }: { value?: string; onChange?: (e: React.ChangeEvent) => void; placeholder?: string; [key: string]: unknown }) => ( + + ), +})) + +vi.mock('@/components/ui/label', () => ({ + Label: ({ children, ...props }: { children: React.ReactNode; [key: string]: unknown }) => ( + + ), +})) + +function makeQuestion(overrides: Record = {}) { + return { + id: 'q-1', + sessionID: 'sess-1', + directory: '/test/dir', + questions: [ + { + question: 'Install Rust?', + header: 'Dependencies', + options: [ + { label: 'Yes, install Rust', description: 'Installs via rustup' }, + { label: 'No, skip', description: 'Skip installation' }, + ], + multiple: false, + custom: true, + }, + ], + tool: { messageID: 'msg-1', callID: 'call-1' }, + ...overrides, + } +} + +let mockContextValue: Record + +function setMockContext(overrides: Record = {}) { + mockContextValue = { + currentQuestion: makeQuestion(), + pendingQuestions: [makeQuestion()], + respondToQuestion: mockRespondToQuestion, + rejectQuestion: mockRejectQuestion, + addQuestion: vi.fn(), + removeQuestion: vi.fn(), + dismissDialog: mockDismissDialog, + isDialogDismissed: false, + ...overrides, + } +} + +describe('QuestionDialog', () => { + beforeEach(() => { + vi.clearAllMocks() + setMockContext() + }) + + it('should not render when there is no current question', () => { + setMockContext({ currentQuestion: null, pendingQuestions: [] }) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should not render when dialog is dismissed', () => { + setMockContext({ isDialogDismissed: true }) + const { container } = render() + expect(container.firstChild).toBeNull() + }) + + it('should render question header and text', () => { + render() + expect(screen.getByText('Dependencies')).toBeInTheDocument() + expect(screen.getByText('Install Rust?')).toBeInTheDocument() + }) + + it('should render all options', () => { + render() + expect(screen.getByText('Yes, install Rust')).toBeInTheDocument() + expect(screen.getByText('No, skip')).toBeInTheDocument() + expect(screen.getByText('Installs via rustup')).toBeInTheDocument() + expect(screen.getByText('Skip installation')).toBeInTheDocument() + }) + + it('should render Submit and Skip buttons', () => { + render() + expect(screen.getByText('Submit')).toBeInTheDocument() + expect(screen.getByText('Skip')).toBeInTheDocument() + }) + + it('should call dismissDialog when X button is clicked', async () => { + const user = userEvent.setup() + render() + + const dismissButton = screen.getByTitle('Dismiss dialog (answer inline instead)') + await user.click(dismissButton) + + expect(mockDismissDialog).toHaveBeenCalledTimes(1) + expect(mockRejectQuestion).not.toHaveBeenCalled() + }) + + it('should call rejectQuestion when Skip is clicked', async () => { + mockRejectQuestion.mockResolvedValueOnce(undefined) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Skip')) + + expect(mockRejectQuestion).toHaveBeenCalledWith('q-1') + }) + + it('should show error when submitting without selection', async () => { + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Submit')) + + expect(screen.getByText('Please select at least one option or provide a custom answer')).toBeInTheDocument() + expect(mockRespondToQuestion).not.toHaveBeenCalled() + }) + + it('should select an option and submit', async () => { + mockRespondToQuestion.mockResolvedValueOnce(undefined) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Yes, install Rust')) + await user.click(screen.getByText('Submit')) + + expect(mockRespondToQuestion).toHaveBeenCalledWith('q-1', [['Yes, install Rust']]) + }) + + it('should show custom answer input when custom is not false', () => { + render() + expect(screen.getByPlaceholderText('Type your answer...')).toBeInTheDocument() + }) + + it('should not show custom answer input when custom is false', () => { + setMockContext({ + currentQuestion: makeQuestion({ + questions: [ + { + question: 'Pick one', + header: 'Choice', + options: [{ label: 'A', description: 'Option A' }], + custom: false, + }, + ], + }), + pendingQuestions: [makeQuestion()], + }) + render() + expect(screen.queryByPlaceholderText('Type your answer...')).not.toBeInTheDocument() + }) + + it('should show pending count badge when multiple questions exist', () => { + setMockContext({ + pendingQuestions: [makeQuestion(), makeQuestion({ id: 'q-2' })], + }) + render() + expect(screen.getByText('+1 more')).toBeInTheDocument() + }) + + it('should show error when API fails during submit', async () => { + mockRespondToQuestion.mockRejectedValueOnce(new Error('Network error')) + const user = userEvent.setup() + render() + + await user.click(screen.getByText('Yes, install Rust')) + await user.click(screen.getByText('Submit')) + + expect(screen.getByText('Network error')).toBeInTheDocument() + }) +}) diff --git a/frontend/src/components/question/QuestionDialog.tsx b/frontend/src/components/question/QuestionDialog.tsx index 26b361fd..a586ba90 100644 --- a/frontend/src/components/question/QuestionDialog.tsx +++ b/frontend/src/components/question/QuestionDialog.tsx @@ -7,7 +7,7 @@ import { Label } from '@/components/ui/label' import { HelpCircle, X, Send, Loader2 } from 'lucide-react' export function QuestionDialog() { - const { currentQuestion, respondToQuestion, rejectQuestion, pendingQuestions } = useQuestionContext() + const { currentQuestion, respondToQuestion, rejectQuestion, pendingQuestions, dismissDialog, isDialogDismissed } = useQuestionContext() const [selectedAnswers, setSelectedAnswers] = useState>(new Map()) const [customAnswers, setCustomAnswers] = useState>(new Map()) const [isSubmitting, setIsSubmitting] = useState(false) @@ -81,7 +81,7 @@ export function QuestionDialog() { } }, [currentQuestion, rejectQuestion]) - if (!currentQuestion) { + if (!currentQuestion || isDialogDismissed) { return null } @@ -98,7 +98,7 @@ export function QuestionDialog() {
)} - diff --git a/frontend/src/contexts/QuestionContext.test.tsx b/frontend/src/contexts/QuestionContext.test.tsx new file mode 100644 index 00000000..d3f50c10 --- /dev/null +++ b/frontend/src/contexts/QuestionContext.test.tsx @@ -0,0 +1,320 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { renderHook, act } from '@testing-library/react' +import type { ReactNode } from 'react' +import { QuestionProvider, useQuestionContext, questionEvents } from './QuestionContext' + +vi.mock('@/config', () => ({ + API_BASE_URL: 'http://localhost:5001', +})) + +const mockFetch = vi.fn() +globalThis.fetch = mockFetch + +function createWrapper() { + return function Wrapper({ children }: { children: ReactNode }) { + return {children} + } +} + +function makeQuestion(overrides: Record = {}) { + return { + id: 'q-1', + sessionID: 'sess-1', + directory: '/test/dir', + questions: [ + { + question: 'Install Rust?', + header: 'Dependencies', + options: [ + { label: 'Yes, install Rust', description: 'Installs via rustup' }, + { label: 'No, skip', description: 'Skip installation' }, + ], + multiple: false, + custom: false, + }, + ], + tool: { messageID: 'msg-1', callID: 'call-1' }, + ...overrides, + } +} + +describe('QuestionContext', () => { + beforeEach(() => { + vi.clearAllMocks() + mockFetch.mockResolvedValue({ ok: true, json: async () => [] }) + questionEvents.listeners.clear() + }) + + afterEach(() => { + questionEvents.listeners.clear() + }) + + it('should throw when used outside provider', () => { + expect(() => { + renderHook(() => useQuestionContext()) + }).toThrow('useQuestionContext must be used within QuestionProvider') + }) + + it('should start with no pending questions', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + expect(result.current.pendingQuestions).toEqual([]) + expect(result.current.currentQuestion).toBeNull() + expect(result.current.isDialogDismissed).toBe(false) + }) + + it('should add a question via addQuestion', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + + expect(result.current.pendingQuestions).toHaveLength(1) + expect(result.current.currentQuestion?.id).toBe('q-1') + }) + + it('should not add duplicate questions', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + result.current.addQuestion(makeQuestion()) + }) + + expect(result.current.pendingQuestions).toHaveLength(1) + }) + + it('should remove a question via removeQuestion', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + expect(result.current.pendingQuestions).toHaveLength(1) + + act(() => { + result.current.removeQuestion('q-1') + }) + expect(result.current.pendingQuestions).toHaveLength(0) + expect(result.current.currentQuestion).toBeNull() + }) + + it('should not re-add a question that was already answered', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + act(() => { + result.current.removeQuestion('q-1') + }) + act(() => { + result.current.addQuestion(makeQuestion()) + }) + + expect(result.current.pendingQuestions).toHaveLength(0) + }) + + it('should respond to a question via API', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] }) + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => 'ok' }) + + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + + await act(async () => { + await result.current.respondToQuestion('q-1', [['Yes, install Rust']]) + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:5001/api/opencode/question/q-1/reply?directory=%2Ftest%2Fdir', + expect.objectContaining({ + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ answers: [['Yes, install Rust']] }), + }) + ) + expect(result.current.pendingQuestions).toHaveLength(0) + }) + + it('should throw when responding to a nonexistent question', async () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + await expect( + act(async () => { + await result.current.respondToQuestion('nonexistent', [['Yes']]) + }) + ).rejects.toThrow('Question not found') + }) + + it('should throw when API returns error on respond', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] }) + mockFetch.mockResolvedValueOnce({ ok: false, text: async () => 'Server error' }) + + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + + await expect( + act(async () => { + await result.current.respondToQuestion('q-1', [['Yes, install Rust']]) + }) + ).rejects.toThrow('Failed to respond to question: Server error') + }) + + it('should reject a question via API', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] }) + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => 'ok' }) + + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + + await act(async () => { + await result.current.rejectQuestion('q-1') + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:5001/api/opencode/question/q-1/reject?directory=%2Ftest%2Fdir', + expect.objectContaining({ method: 'POST' }) + ) + expect(result.current.pendingQuestions).toHaveLength(0) + }) + + it('should dismiss dialog without rejecting the question', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + + act(() => { + result.current.dismissDialog() + }) + + expect(result.current.isDialogDismissed).toBe(true) + expect(result.current.pendingQuestions).toHaveLength(1) + expect(result.current.currentQuestion?.id).toBe('q-1') + }) + + it('should reset isDialogDismissed when a new question is added', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion()) + }) + act(() => { + result.current.dismissDialog() + }) + expect(result.current.isDialogDismissed).toBe(true) + + act(() => { + result.current.addQuestion(makeQuestion({ id: 'q-2' })) + }) + expect(result.current.isDialogDismissed).toBe(false) + }) + + it('should queue multiple questions and expose first as currentQuestion', () => { + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion({ id: 'q-1' })) + result.current.addQuestion(makeQuestion({ id: 'q-2' })) + }) + + expect(result.current.pendingQuestions).toHaveLength(2) + expect(result.current.currentQuestion?.id).toBe('q-1') + }) + + it('should use sessionID as directory fallback when directory is missing', async () => { + mockFetch.mockResolvedValueOnce({ ok: true, json: async () => [] }) + mockFetch.mockResolvedValueOnce({ ok: true, text: async () => 'ok' }) + + const { result } = renderHook(() => useQuestionContext(), { + wrapper: createWrapper(), + }) + + act(() => { + result.current.addQuestion(makeQuestion({ directory: undefined })) + }) + + await act(async () => { + await result.current.respondToQuestion('q-1', [['Yes, install Rust']]) + }) + + expect(mockFetch).toHaveBeenCalledWith( + 'http://localhost:5001/api/opencode/question/q-1/reply?directory=sess-1', + expect.anything() + ) + }) +}) + +describe('questionEvents', () => { + afterEach(() => { + questionEvents.listeners.clear() + }) + + it('should emit to subscribers', () => { + const listener = vi.fn() + questionEvents.subscribe(listener) + + const q = makeQuestion() + questionEvents.emit(q) + + expect(listener).toHaveBeenCalledWith(q) + }) + + it('should unsubscribe correctly', () => { + const listener = vi.fn() + const unsub = questionEvents.subscribe(listener) + unsub() + + questionEvents.emit(makeQuestion()) + + expect(listener).not.toHaveBeenCalled() + }) + + it('should support multiple subscribers', () => { + const listener1 = vi.fn() + const listener2 = vi.fn() + questionEvents.subscribe(listener1) + questionEvents.subscribe(listener2) + + questionEvents.emit(makeQuestion()) + + expect(listener1).toHaveBeenCalled() + expect(listener2).toHaveBeenCalled() + }) +}) diff --git a/frontend/src/contexts/QuestionContext.tsx b/frontend/src/contexts/QuestionContext.tsx index 26932f34..a143a98f 100644 --- a/frontend/src/contexts/QuestionContext.tsx +++ b/frontend/src/contexts/QuestionContext.tsx @@ -33,6 +33,8 @@ interface QuestionContextValue { rejectQuestion: (requestId: string) => Promise addQuestion: (question: QuestionRequest) => void removeQuestion: (requestId: string) => void + dismissDialog: () => void + isDialogDismissed: boolean } const QuestionContext = createContext(null) @@ -40,6 +42,7 @@ const QuestionContext = createContext(null) export function QuestionProvider({ children }: { children: React.ReactNode }) { const [pendingQuestions, setPendingQuestions] = useState([]) const answeredRef = useRef>(new Set()) + const [isDialogDismissed, setIsDialogDismissed] = useState(false) const currentQuestion = useMemo(() => { return pendingQuestions[0] ?? null @@ -55,6 +58,7 @@ export function QuestionProvider({ children }: { children: React.ReactNode }) { } return [...prev, question] }) + setIsDialogDismissed(false) }, []) const removeQuestion = useCallback((requestId: string) => { @@ -103,6 +107,10 @@ export function QuestionProvider({ children }: { children: React.ReactNode }) { removeQuestion(requestId) }, [pendingQuestions, removeQuestion]) + const dismissDialog = useCallback(() => { + setIsDialogDismissed(true) + }, []) + useEffect(() => { const fetchPendingQuestions = async () => { try { @@ -136,8 +144,10 @@ export function QuestionProvider({ children }: { children: React.ReactNode }) { rejectQuestion, addQuestion, removeQuestion, + dismissDialog, + isDialogDismissed, }), - [currentQuestion, pendingQuestions, respondToQuestion, rejectQuestion, addQuestion, removeQuestion] + [currentQuestion, pendingQuestions, respondToQuestion, rejectQuestion, addQuestion, removeQuestion, dismissDialog, isDialogDismissed] ) return {children} diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index 5a915f42..3daf2712 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query' import { useOpenCodeClient } from './useOpenCode' import type { SSEEvent, MessageListResponse } from '@/api/types' import { permissionEvents } from './usePermissionRequests' +import { questionEvents } from '@/contexts/QuestionContext' import { showToast } from '@/lib/toast' import { settingsApi } from '@/api/settings' import { useSessionStatus } from '@/stores/sessionStatusStore' @@ -355,6 +356,12 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string) } break + case 'question.asked': + if ('id' in event.properties && 'sessionID' in event.properties && 'questions' in event.properties) { + questionEvents.emit(event.properties as Parameters[0]) + } + break + default: break } From e8d1a2489671a52d5ca2ec716d9af11b4a0945ef Mon Sep 17 00:00:00 2001 From: engineer Date: Sat, 14 Feb 2026 16:39:44 -0800 Subject: [PATCH 2/2] fix: remove unused 'within' import to pass lint --- frontend/src/components/message/ToolCallPart.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/components/message/ToolCallPart.test.tsx b/frontend/src/components/message/ToolCallPart.test.tsx index bc195199..6dc55360 100644 --- a/frontend/src/components/message/ToolCallPart.test.tsx +++ b/frontend/src/components/message/ToolCallPart.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from 'vitest' -import { render, screen, within } from '@testing-library/react' +import { render, screen } from '@testing-library/react' import userEvent from '@testing-library/user-event' import { ToolCallPart } from './ToolCallPart'