From a55d9b368e8edd41e109b98533ffcd20fdcdec09 Mon Sep 17 00:00:00 2001 From: Chris Scott <> Date: Mon, 27 Apr 2026 23:18:58 +0000 Subject: [PATCH 1/3] fix(frontend): refresh active session after SSE reconnect Refetch the active session and messages when the SSE stream reconnects so missed updates appear without navigating away. --- frontend/src/hooks/useSSE.test.tsx | 128 +++++++++++++++++++++++++++++ frontend/src/hooks/useSSE.ts | 15 +++- 2 files changed, 142 insertions(+), 1 deletion(-) create mode 100644 frontend/src/hooks/useSSE.test.tsx diff --git a/frontend/src/hooks/useSSE.test.tsx b/frontend/src/hooks/useSSE.test.tsx new file mode 100644 index 00000000..ec184383 --- /dev/null +++ b/frontend/src/hooks/useSSE.test.tsx @@ -0,0 +1,128 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { act, renderHook, waitFor } from '@testing-library/react' +import type { ReactNode } from 'react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { useSSE } from './useSSE' + +const mocks = vi.hoisted(() => ({ + getSessionStatuses: vi.fn(), +})) + +vi.mock('@/api/opencode', () => ({ + OpenCodeClient: vi.fn(() => ({ + getSessionStatuses: mocks.getSessionStatuses, + })), +})) + +vi.mock('@/api/settings', () => ({ + settingsApi: { + reloadOpenCodeConfig: vi.fn(), + }, +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { + dismiss: vi.fn(), + error: vi.fn(), + info: vi.fn(), + loading: vi.fn(), + success: vi.fn(), + }, +})) + +class MockEventSource { + static instances: MockEventSource[] = [] + + onopen: (() => void) | null = null + onerror: (() => void) | null = null + onmessage: ((event: MessageEvent) => void) | null = null + private listeners = new Map void>>() + + constructor() { + MockEventSource.instances.push(this) + } + + addEventListener(type: string, listener: (event: MessageEvent) => void) { + const listeners = this.listeners.get(type) ?? [] + listeners.push(listener) + this.listeners.set(type, listeners) + } + + close() {} + + emit(type: string, data: unknown) { + const event = { data: JSON.stringify(data) } as MessageEvent + this.listeners.get(type)?.forEach((listener) => listener(event)) + } +} + +describe('useSSE', () => { + const originalEventSource = globalThis.EventSource + const originalFetch = globalThis.fetch + + beforeEach(() => { + vi.clearAllMocks() + MockEventSource.instances = [] + mocks.getSessionStatuses.mockResolvedValue({}) + globalThis.EventSource = MockEventSource as unknown as typeof EventSource + globalThis.fetch = vi.fn(() => Promise.resolve({ ok: true } as Response)) + }) + + afterEach(() => { + globalThis.EventSource = originalEventSource + globalThis.fetch = originalFetch + }) + + it('invalidates active session data after reconnecting', async () => { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries') + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ) + + const { result, unmount } = renderHook( + () => useSSE('http://localhost:5551', '/repo', 'session-1'), + { wrapper } + ) + + await waitFor(() => expect(MockEventSource.instances).toHaveLength(1)) + + act(() => { + MockEventSource.instances[0].emit('connected', { clientId: 'client-1' }) + }) + + await waitFor(() => expect(result.current.isConnected).toBe(true)) + invalidateQueries.mockClear() + + act(() => { + MockEventSource.instances[0].onerror?.() + }) + + await waitFor(() => expect(result.current.isConnected).toBe(false)) + + act(() => { + window.dispatchEvent(new Event('focus')) + }) + + await waitFor(() => expect(MockEventSource.instances).toHaveLength(2)) + + act(() => { + MockEventSource.instances[1].emit('connected', { clientId: 'client-2' }) + }) + + await waitFor(() => { + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['opencode', 'session', 'http://localhost:5551', 'session-1', '/repo'], + }) + expect(invalidateQueries).toHaveBeenCalledWith({ + queryKey: ['opencode', 'messages', 'http://localhost:5551', 'session-1', '/repo'], + }) + }) + + unmount() + }) +}) diff --git a/frontend/src/hooks/useSSE.ts b/frontend/src/hooks/useSSE.ts index 609b70f3..5a71bb58 100644 --- a/frontend/src/hooks/useSSE.ts +++ b/frontend/src/hooks/useSSE.ts @@ -329,6 +329,18 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string, } }, [client, setSessionStatus]) + const syncCurrentSession = useCallback(() => { + const sessionId = sessionIdRef.current + if (!sessionId || !opcodeUrl) return + + queryClient.invalidateQueries({ + queryKey: ['opencode', 'session', opcodeUrl, sessionId, directory], + }) + queryClient.invalidateQueries({ + queryKey: ['opencode', 'messages', opcodeUrl, sessionId, directory], + }) + }, [queryClient, opcodeUrl, directory]) + useEffect(() => { mountedRef.current = true @@ -351,6 +363,7 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string, if (connected) { setError(null) fetchInitialData() + syncCurrentSession() sseManager.reportVisibility(document.visibilityState === 'visible', sessionIdRef.current) } else { setError('Connection lost. Reconnecting...') @@ -381,7 +394,7 @@ export const useSSE = (opcodeUrl: string | null | undefined, directory?: string, unsubscribe() directoryCleanup?.() } - }, [opcodeUrl, directory, handleSSEEvent, fetchInitialData]) + }, [opcodeUrl, directory, handleSSEEvent, fetchInitialData, syncCurrentSession]) useEffect(() => { if (isConnected && document.visibilityState === 'visible') { From 291ec741136d06008b80f85178d8e23c281c7f78 Mon Sep 17 00:00:00 2001 From: Chris Scott <> Date: Mon, 27 Apr 2026 23:38:16 +0000 Subject: [PATCH 2/3] fix(frontend): navigate pending actions without session cache Track pending action session directories so header notifications can open the correct session even when React Query has not cached that session yet. --- backend/src/services/sse-aggregator.ts | 5 +- frontend/src/api/opencode.ts | 8 +- frontend/src/api/types.ts | 2 + frontend/src/contexts/EventContext.test.tsx | 155 ++++++++++++++++++++ frontend/src/contexts/EventContext.tsx | 74 ++++++++-- frontend/src/pages/SessionDetail.tsx | 9 +- 6 files changed, 238 insertions(+), 15 deletions(-) create mode 100644 frontend/src/contexts/EventContext.test.tsx diff --git a/backend/src/services/sse-aggregator.ts b/backend/src/services/sse-aggregator.ts index e0b5a09f..6971772f 100644 --- a/backend/src/services/sse-aggregator.ts +++ b/backend/src/services/sse-aggregator.ts @@ -205,12 +205,15 @@ class SSEAggregator { } private broadcastToDirectory(directory: string, event: string, data: string): void { + let clientData = data + try { const parsed = JSON.parse(data) as SSEEvent this.handleEvent(directory, parsed) this.eventListeners.forEach(listener => { try { listener(directory, parsed) } catch { /* ignore listener errors */ } }) + clientData = JSON.stringify({ ...parsed, directory }) } catch { // Ignore parse errors } @@ -218,7 +221,7 @@ class SSEAggregator { this.clients.forEach((client) => { if (client.directories.has(directory)) { try { - client.callback(event, data) + client.callback(event, clientData) } catch (error) { logger.error(`Failed to send to client ${client.id}:`, error) } diff --git a/frontend/src/api/opencode.ts b/frontend/src/api/opencode.ts index eff42c73..5b45823e 100644 --- a/frontend/src/api/opencode.ts +++ b/frontend/src/api/opencode.ts @@ -11,6 +11,7 @@ type CommandListResponse = paths['/command']['get']['responses']['200']['content type CommandRequest = NonNullable['content']['application/json'] type ShellRequest = NonNullable['content']['application/json'] type AgentListResponse = paths['/agent']['get']['responses']['200']['content']['application/json'] +type PermissionListResponse = paths['/permission']['get']['responses']['200']['content']['application/json'] type QuestionListResponse = paths['/question']['get']['responses']['200']['content']['application/json'] type SendPromptResponse = paths['/session/{sessionID}/message']['post']['responses']['200']['content']['application/json'] type LspStatusResponse = paths['/lsp']['get']['responses']['200']['content']['application/json'] @@ -183,6 +184,12 @@ export class OpenCodeClient { }) } + async listPendingPermissions() { + return fetchWrapper(`${this.baseURL}/permission`, { + params: this.getParams(), + }) + } + async replyToQuestion(requestID: string, answers: string[][]) { return fetchWrapper(`${this.baseURL}/question/${requestID}/reply`, { method: 'POST', @@ -248,4 +255,3 @@ export class OpenCodeClient { export const createOpenCodeClient = (baseURL: string, directory?: string) => { return new OpenCodeClient(baseURL, directory) } - diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d58bbdd1..aa33fafe 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -99,6 +99,7 @@ export interface SSETodoUpdatedEvent { export interface SSEPermissionAskedEvent { type: 'permission.asked' properties: PermissionRequest + directory?: string } export interface SSEPermissionRepliedEvent { @@ -113,6 +114,7 @@ export interface SSEPermissionRepliedEvent { export interface SSEQuestionAskedEvent { type: 'question.asked' properties: QuestionRequest + directory?: string } export interface SSEQuestionRepliedEvent { diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx new file mode 100644 index 00000000..ede5b208 --- /dev/null +++ b/frontend/src/contexts/EventContext.test.tsx @@ -0,0 +1,155 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import type { ReactNode } from 'react' +import { MemoryRouter, useLocation } from 'react-router-dom' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { QuestionRequest } from '@/api/types' +import { EventProvider, useQuestions } from './EventContext' + +const mocks = vi.hoisted(() => ({ + listRepos: vi.fn(), + listPendingPermissions: vi.fn(), + listPendingQuestions: vi.fn(), + subscribeToSSE: vi.fn(), + addSSEDirectory: vi.fn(), + ensureSSEConnected: vi.fn(), +})) + +vi.mock('@/api/repos', () => ({ + listRepos: mocks.listRepos, +})) + +vi.mock('@/api/opencode', () => ({ + OpenCodeClient: vi.fn(() => ({ + listPendingPermissions: mocks.listPendingPermissions, + listPendingQuestions: mocks.listPendingQuestions, + })), +})) + +vi.mock('@/lib/sseManager', () => ({ + subscribeToSSE: mocks.subscribeToSSE, + addSSEDirectory: mocks.addSSEDirectory, + ensureSSEConnected: mocks.ensureSSEConnected, +})) + +vi.mock('@/lib/toast', () => ({ + showToast: { + error: vi.fn(), + info: vi.fn(), + }, +})) + +const pendingQuestion: QuestionRequest = { + id: 'question-1', + sessionID: 'session-1', + questions: [ + { + question: 'Continue?', + header: 'Confirm', + options: [ + { + label: 'Yes', + description: 'Continue', + }, + ], + multiple: false, + }, + ], +} + +function Harness() { + const { current, pendingCount, syncForSession, navigateToCurrent } = useQuestions() + const location = useLocation() + + return ( +
+
{pendingCount}
+
{current?.id ?? 'none'}
+
{location.pathname}
+ + +
+ ) +} + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: false }, + }, + }) + + return ({ children }: { children: ReactNode }) => ( + + + {children} + + + ) +} + +describe('EventProvider questions', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.listRepos.mockResolvedValue([]) + mocks.listPendingPermissions.mockResolvedValue([]) + mocks.listPendingQuestions.mockResolvedValue([]) + mocks.subscribeToSSE.mockReturnValue(() => {}) + mocks.addSSEDirectory.mockReturnValue(() => {}) + mocks.ensureSSEConnected.mockResolvedValue(true) + }) + + it('syncs missed pending questions for a session', async () => { + mocks.listPendingQuestions.mockResolvedValue([pendingQuestion]) + + render(, { wrapper: createWrapper() }) + + await userEvent.click(screen.getByRole('button', { name: 'Sync' })) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('1') + expect(screen.getByTestId('current')).toHaveTextContent('question-1') + }) + }) + + it('clears stale pending questions for a session', async () => { + mocks.listPendingQuestions + .mockResolvedValueOnce([pendingQuestion]) + .mockResolvedValueOnce([]) + + render(, { wrapper: createWrapper() }) + + await userEvent.click(screen.getByRole('button', { name: 'Sync' })) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('1') + }) + + await userEvent.click(screen.getByRole('button', { name: 'Sync' })) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('0') + expect(screen.getByTestId('current')).toHaveTextContent('none') + }) + }) + + it('navigates to a synced pending question without session query cache', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + mocks.listPendingQuestions.mockResolvedValue([pendingQuestion]) + + render(, { wrapper: createWrapper() }) + + await userEvent.click(screen.getByRole('button', { name: 'Sync' })) + + await waitFor(() => { + expect(screen.getByTestId('current')).toHaveTextContent('question-1') + }) + + await userEvent.click(screen.getByRole('button', { name: 'Navigate' })) + + await waitFor(() => { + expect(screen.getByTestId('path')).toHaveTextContent('/repos/123/sessions/session-1') + }) + }) +}) diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index 2aa491e1..dc308fa7 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -107,6 +107,7 @@ interface EventContextValue { getForCallID: (callID: string, sessionID: string) => QuestionRequest | null hasForSession: (sessionID: string) => boolean navigateToCurrent: () => void + syncForSession: (directory: string, sessionID: string) => Promise } getRepoIdForSession: (sessionID: string) => number | null getClient: (sessionID: string) => OpenCodeClient | null @@ -135,6 +136,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const [showPermissionDialog, setShowPermissionDialog] = useState(true) const clientsRef = useRef>(new Map()) + const sessionDirectoriesRef = useRef>(new Map()) const prevPermissionCountRef = useRef(0) const initialFetchDoneRef = useRef(false) const MAX_CACHED_CLIENTS = 50 @@ -157,7 +159,15 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const currentPermission = allPermissions[0] ?? null const currentQuestion = allQuestions[0] ?? null - const findSessionInCache = useCallback((sessionID: string): { url: string; directory: string } | null => { + const rememberSessionDirectory = useCallback((sessionID: string, directory?: string) => { + if (!directory) return + sessionDirectoriesRef.current.set(sessionID, directory) + }, []) + + const findSessionDirectory = useCallback((sessionID: string): string | null => { + const remembered = sessionDirectoriesRef.current.get(sessionID) + if (remembered) return remembered + const cache = queryClient.getQueryCache() const queries = cache.getAll() @@ -166,9 +176,8 @@ export function EventProvider({ children }: { children: React.ReactNode }) { if (key[0] === 'opencode' && key[1] === 'session' && key.length >= 5) { const sessionData = query.state.data as { id: string } | undefined if (sessionData?.id === sessionID) { - const url = key[2] as string const directory = key[4] as string - if (url && directory) return { url, directory } + if (directory) return directory } } } @@ -180,9 +189,8 @@ export function EventProvider({ children }: { children: React.ReactNode }) { if (!sessionsList) continue const found = sessionsList.find(s => s.id === sessionID) if (found) { - const url = key[2] as string const directory = key[3] as string - if (url && directory) return { url, directory } + if (directory) return directory } } } @@ -190,13 +198,19 @@ export function EventProvider({ children }: { children: React.ReactNode }) { return null }, [queryClient]) + const findSessionInCache = useCallback((sessionID: string): { url: string; directory: string } | null => { + const directory = findSessionDirectory(sessionID) + if (!directory) return null + return { url: OPENCODE_API_ENDPOINT, directory } + }, [findSessionDirectory]) + const getRepoIdForSession = useCallback((sessionID: string): number | null => { if (!repos) return null - const result = findSessionInCache(sessionID) - if (!result) return null - const repo = repos.find(r => r.fullPath === result.directory) + const directory = findSessionDirectory(sessionID) + if (!directory) return null + const repo = repos.find(r => r.fullPath === directory) return repo?.id ?? null - }, [repos, findSessionInCache]) + }, [repos, findSessionDirectory]) const getClient = useCallback((sessionID: string): OpenCodeClient | null => { const result = findSessionInCache(sessionID) @@ -231,6 +245,18 @@ export function EventProvider({ children }: { children: React.ReactNode }) { removeFromSessionKeyedState(setQuestionsBySession, requestID, sessionID) }, []) + const replaceQuestionsForSession = useCallback((sessionID: string, questions: QuestionRequest[]) => { + setQuestionsBySession(prev => { + const next = { ...prev } + if (questions.length === 0) { + delete next[sessionID] + return next + } + next[sessionID] = questions + return next + }) + }, []) + useEffect(() => { const permissionCount = allPermissions.length if (permissionCount > prevPermissionCountRef.current && permissionCount > 0 && !showPermissionDialog) { @@ -346,9 +372,19 @@ export function EventProvider({ children }: { children: React.ReactNode }) { for (const directory of uniqueDirectories) { try { const client = new OpenCodeClient(OPENCODE_API_ENDPOINT, directory) + const pendingPermissions = await client.listPendingPermissions() + if (pendingPermissions && pendingPermissions.length > 0) { + pendingPermissions.forEach(permission => { + rememberSessionDirectory(permission.sessionID, directory) + addPermission(permission) + }) + } const pendingQuestions = await client.listPendingQuestions() if (pendingQuestions && pendingQuestions.length > 0) { - pendingQuestions.forEach(addQuestion) + pendingQuestions.forEach(question => { + rememberSessionDirectory(question.sessionID, directory) + addQuestion(question) + }) } } catch (error) { if (import.meta.env.DEV) { @@ -356,7 +392,17 @@ export function EventProvider({ children }: { children: React.ReactNode }) { } } } - }, [repos, addQuestion]) + }, [repos, addPermission, addQuestion, rememberSessionDirectory]) + + const syncQuestionsForSession = useCallback(async (directory: string, sessionID: string) => { + const client = new OpenCodeClient(OPENCODE_API_ENDPOINT, directory) + const pendingQuestions = await client.listPendingQuestions() + rememberSessionDirectory(sessionID, directory) + replaceQuestionsForSession( + sessionID, + (pendingQuestions ?? []).filter(question => question.sessionID === sessionID) + ) + }, [rememberSessionDirectory, replaceQuestionsForSession]) useEffect(() => { const handleSSEMessage = (data: unknown) => { @@ -367,6 +413,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { switch (event.type) { case 'permission.asked': if ('permission' in event.properties && 'sessionID' in event.properties) { + rememberSessionDirectory(event.properties.sessionID as string, event.directory) addPermission(event.properties as PermissionRequest) } break @@ -380,6 +427,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { break case 'question.asked': if ('questions' in event.properties && 'sessionID' in event.properties && 'id' in event.properties) { + rememberSessionDirectory(event.properties.sessionID as string, event.directory) addQuestion(event.properties as QuestionRequest) } break @@ -436,7 +484,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const unsubscribe = subscribeToSSE(handleSSEMessage, handleStatusChange) return unsubscribe - }, [addPermission, removePermission, addQuestion, removeQuestion, fetchInitialPendingData, queryClient]) + }, [addPermission, removePermission, addQuestion, removeQuestion, rememberSessionDirectory, fetchInitialPendingData, queryClient]) useEffect(() => { if (!repos || repos.length === 0) return @@ -487,6 +535,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { getForCallID: getQuestionForCallID, hasForSession: hasQuestionsForSession, navigateToCurrent: navigateToCurrentQuestion, + syncForSession: syncQuestionsForSession, }, getRepoIdForSession, getClient, @@ -509,6 +558,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) { getQuestionForCallID, hasQuestionsForSession, navigateToCurrentQuestion, + syncQuestionsForSession, getRepoIdForSession, getClient, ]) diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index fbad39dd..abf7e4ef 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -156,7 +156,7 @@ export function SessionDetail() { const isEditingMessage = useUIState((state) => state.isEditingMessage); const { isEnabled: ttsEnabled } = useTTS(); const setSessionStatus = useSessionStatus((state) => state.setStatus); - const { current: currentQuestion, reply: replyToQuestion, reject: rejectQuestion } = useQuestions(); + const { current: currentQuestion, reply: replyToQuestion, reject: rejectQuestion, syncForSession: syncQuestionsForSession } = useQuestions(); const sessionStatus = useSessionStatusForSession(sessionId); const isSessionActive = sessionStatus.type === 'busy' || sessionStatus.type === 'compact' || sessionStatus.type === 'retry'; @@ -190,6 +190,13 @@ export function SessionDetail() { } }, [sessionId, minimizedQuestion]) + useEffect(() => { + if (!repoDirectory || !sessionId) return + syncQuestionsForSession(repoDirectory, sessionId).catch(() => { + showToast.error('Failed to load pending questions') + }) + }, [repoDirectory, sessionId, syncQuestionsForSession]) + const handleNewSession = useCallback(async () => { try { const newSession = await createSession.mutateAsync({ agent: undefined }); From 86d9f5b0ac7dde6be5cc47a0ebde891908395830 Mon Sep 17 00:00:00 2001 From: Chris Scott <99081550+chriswritescode-dev@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:31:19 -0400 Subject: [PATCH 3/3] fix(frontend): reconcile pending questions by directory and improve TTS fallback Replace per-session question replacement with directory-scoped reconciliation that groups questions by session and removes stale entries. Auto-dismiss questions after reply/reject succeeds. Extract TTS text extraction into reusable helpers and fall back to previous assistant message when the latest one has no text content. --- frontend/src/contexts/EventContext.test.tsx | 109 +++++++++++++++++- frontend/src/contexts/EventContext.tsx | 61 ++++++---- .../useAutoPlayLastResponse.test.tsx | 48 +++++++- frontend/src/hooks/useAutoPlayLastResponse.ts | 17 +++ frontend/src/pages/SessionDetail.tsx | 11 +- 5 files changed, 219 insertions(+), 27 deletions(-) diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx index ede5b208..8aa4b9d9 100644 --- a/frontend/src/contexts/EventContext.test.tsx +++ b/frontend/src/contexts/EventContext.test.tsx @@ -11,6 +11,8 @@ const mocks = vi.hoisted(() => ({ listRepos: vi.fn(), listPendingPermissions: vi.fn(), listPendingQuestions: vi.fn(), + replyToQuestion: vi.fn(), + rejectQuestion: vi.fn(), subscribeToSSE: vi.fn(), addSSEDirectory: vi.fn(), ensureSSEConnected: vi.fn(), @@ -24,6 +26,8 @@ vi.mock('@/api/opencode', () => ({ OpenCodeClient: vi.fn(() => ({ listPendingPermissions: mocks.listPendingPermissions, listPendingQuestions: mocks.listPendingQuestions, + replyToQuestion: mocks.replyToQuestion, + rejectQuestion: mocks.rejectQuestion, })), })) @@ -58,8 +62,26 @@ const pendingQuestion: QuestionRequest = { ], } +const secondPendingQuestion: QuestionRequest = { + id: 'question-2', + sessionID: 'session-2', + questions: [ + { + question: 'Deploy?', + header: 'Deploy', + options: [ + { + label: 'Yes', + description: 'Deploy changes', + }, + ], + multiple: false, + }, + ], +} + function Harness() { - const { current, pendingCount, syncForSession, navigateToCurrent } = useQuestions() + const { current, pendingCount, syncForSession, navigateToCurrent, reject, reply } = useQuestions() const location = useLocation() return ( @@ -69,6 +91,8 @@ function Harness() {
{location.pathname}
+ + ) } @@ -95,6 +119,8 @@ describe('EventProvider questions', () => { mocks.listRepos.mockResolvedValue([]) mocks.listPendingPermissions.mockResolvedValue([]) mocks.listPendingQuestions.mockResolvedValue([]) + mocks.replyToQuestion.mockResolvedValue(undefined) + mocks.rejectQuestion.mockResolvedValue(undefined) mocks.subscribeToSSE.mockReturnValue(() => {}) mocks.addSSEDirectory.mockReturnValue(() => {}) mocks.ensureSSEConnected.mockResolvedValue(true) @@ -134,6 +160,49 @@ describe('EventProvider questions', () => { }) }) + it('reconciles pending questions for the whole directory', async () => { + mocks.listPendingQuestions + .mockResolvedValueOnce([pendingQuestion, secondPendingQuestion]) + .mockResolvedValueOnce([pendingQuestion]) + + render(, { wrapper: createWrapper() }) + + await userEvent.click(screen.getByRole('button', { name: 'Sync' })) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('2') + }) + + await userEvent.click(screen.getByRole('button', { name: 'Sync' })) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('1') + expect(screen.getByTestId('current')).toHaveTextContent('question-1') + }) + }) + + it('reconciles stale pending questions after reconnect', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + mocks.listPendingQuestions + .mockResolvedValueOnce([pendingQuestion]) + .mockResolvedValueOnce([]) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('1') + }) + + const lastSubscribeCall = mocks.subscribeToSSE.mock.calls[mocks.subscribeToSSE.mock.calls.length - 1] + const handleStatusChange = lastSubscribeCall[1] as (connected: boolean) => void + handleStatusChange(true) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('0') + expect(screen.getByTestId('current')).toHaveTextContent('none') + }) + }) + it('navigates to a synced pending question without session query cache', async () => { mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) mocks.listPendingQuestions.mockResolvedValue([pendingQuestion]) @@ -152,4 +221,42 @@ describe('EventProvider questions', () => { expect(screen.getByTestId('path')).toHaveTextContent('/repos/123/sessions/session-1') }) }) + + it('clears a pending question after dismiss succeeds', async () => { + mocks.listPendingQuestions.mockResolvedValue([pendingQuestion]) + + render(, { wrapper: createWrapper() }) + + await userEvent.click(screen.getByRole('button', { name: 'Sync' })) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('1') + }) + + await userEvent.click(screen.getByRole('button', { name: 'Dismiss' })) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('0') + expect(screen.getByTestId('current')).toHaveTextContent('none') + }) + }) + + it('clears a pending question after reply succeeds', async () => { + mocks.listPendingQuestions.mockResolvedValue([pendingQuestion]) + + render(, { wrapper: createWrapper() }) + + await userEvent.click(screen.getByRole('button', { name: 'Sync' })) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('1') + }) + + await userEvent.click(screen.getByRole('button', { name: 'Reply' })) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('0') + expect(screen.getByTestId('current')).toHaveTextContent('none') + }) + }) }) diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index dc308fa7..d02fdbfd 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -13,6 +13,20 @@ import { addToSessionKeyedState, removeFromSessionKeyedState } from '@/lib/sessi type PermissionsBySession = Record type QuestionsBySession = Record +function groupQuestionsBySession(questions: QuestionRequest[]): QuestionsBySession { + return questions.reduce((grouped, question) => { + const existing = grouped[question.sessionID] ?? [] + return { + ...grouped, + [question.sessionID]: [...existing, question], + } + }, {}) +} + +function sortQuestionsById(questions: QuestionRequest[]): QuestionRequest[] { + return [...questions].sort((a, b) => a.id.localeCompare(b.id)) +} + function optimisticallyErrorToolPart( queryClient: ReturnType, sessionID: string, @@ -245,17 +259,30 @@ export function EventProvider({ children }: { children: React.ReactNode }) { removeFromSessionKeyedState(setQuestionsBySession, requestID, sessionID) }, []) - const replaceQuestionsForSession = useCallback((sessionID: string, questions: QuestionRequest[]) => { + const reconcileQuestionsForDirectory = useCallback((directory: string, questions: QuestionRequest[]) => { + const grouped = groupQuestionsBySession(questions) + + questions.forEach(question => { + rememberSessionDirectory(question.sessionID, directory) + }) + setQuestionsBySession(prev => { const next = { ...prev } - if (questions.length === 0) { - delete next[sessionID] - return next + + for (const sessionID of Object.keys(prev)) { + if (grouped[sessionID]) continue + if (sessionDirectoriesRef.current.get(sessionID) === directory) { + delete next[sessionID] + } + } + + for (const [sessionID, sessionQuestions] of Object.entries(grouped)) { + next[sessionID] = sortQuestionsById(sessionQuestions) } - next[sessionID] = questions + return next }) - }, []) + }, [rememberSessionDirectory]) useEffect(() => { const permissionCount = allPermissions.length @@ -301,7 +328,8 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const client = getClient(question.sessionID) if (!client) throw new Error('No client found for session') await client.replyToQuestion(requestID, answers) - }, [getClient, questionsBySession]) + removeQuestion(requestID, question.sessionID) + }, [getClient, questionsBySession, removeQuestion]) const rejectQuestion = useCallback(async (requestID: string) => { const connected = await ensureSSEConnected() @@ -319,7 +347,8 @@ export function EventProvider({ children }: { children: React.ReactNode }) { } await client.rejectQuestion(requestID) - }, [getClient, questionsBySession, queryClient]) + removeQuestion(requestID, question.sessionID) + }, [getClient, questionsBySession, queryClient, removeQuestion]) const getPermissionForCallID = useCallback((callID: string, sessionID: string): PermissionRequest | null => { const perms = permissionsBySession[sessionID] ?? [] @@ -380,29 +409,21 @@ export function EventProvider({ children }: { children: React.ReactNode }) { }) } const pendingQuestions = await client.listPendingQuestions() - if (pendingQuestions && pendingQuestions.length > 0) { - pendingQuestions.forEach(question => { - rememberSessionDirectory(question.sessionID, directory) - addQuestion(question) - }) - } + reconcileQuestionsForDirectory(directory, pendingQuestions ?? []) } catch (error) { if (import.meta.env.DEV) { console.warn(`Failed to fetch pending questions for ${directory}:`, error) } } } - }, [repos, addPermission, addQuestion, rememberSessionDirectory]) + }, [repos, addPermission, rememberSessionDirectory, reconcileQuestionsForDirectory]) const syncQuestionsForSession = useCallback(async (directory: string, sessionID: string) => { const client = new OpenCodeClient(OPENCODE_API_ENDPOINT, directory) const pendingQuestions = await client.listPendingQuestions() rememberSessionDirectory(sessionID, directory) - replaceQuestionsForSession( - sessionID, - (pendingQuestions ?? []).filter(question => question.sessionID === sessionID) - ) - }, [rememberSessionDirectory, replaceQuestionsForSession]) + reconcileQuestionsForDirectory(directory, pendingQuestions ?? []) + }, [rememberSessionDirectory, reconcileQuestionsForDirectory]) useEffect(() => { const handleSSEMessage = (data: unknown) => { diff --git a/frontend/src/hooks/__tests__/useAutoPlayLastResponse.test.tsx b/frontend/src/hooks/__tests__/useAutoPlayLastResponse.test.tsx index 359aa39d..90a57efd 100644 --- a/frontend/src/hooks/__tests__/useAutoPlayLastResponse.test.tsx +++ b/frontend/src/hooks/__tests__/useAutoPlayLastResponse.test.tsx @@ -27,7 +27,7 @@ interface MockTTSReturn { activeMessageId: string | null } -import { useAutoPlayLastResponse } from '../useAutoPlayLastResponse' +import { getLatestPlayableAssistantMessage, useAutoPlayLastResponse } from '../useAutoPlayLastResponse' const createMessage = (id: string, completed?: number): MessageWithParts => ({ info: { @@ -70,6 +70,29 @@ const createMessage = (id: string, completed?: number): MessageWithParts => ({ ], }) +const createAssistantMessageWithoutText = (id: string): MessageWithParts => ({ + ...createMessage(id, Date.now()), + parts: [ + { + id: 'part-1', + sessionID: 'test-session', + messageID: id, + type: 'tool', + callID: 'call-1', + tool: 'question', + state: { + status: 'error', + input: {}, + error: 'Question rejected', + time: { + start: Date.now(), + end: Date.now() + 100, + }, + }, + }, + ], +}) + describe('useAutoPlayLastResponse', () => { const mockSpeak = vi.fn() const mockSpeakMessage = vi.fn() @@ -333,3 +356,26 @@ describe('useAutoPlayLastResponse', () => { expect(mockSpeakMessage).not.toHaveBeenCalled() }) }) + +describe('getLatestPlayableAssistantMessage', () => { + it('falls back to the previous assistant message when the latest assistant message has no text', () => { + const playableMessage = createMessage('1', Date.now()) + const erroredMessage = createAssistantMessageWithoutText('2') + + const result = getLatestPlayableAssistantMessage([ + playableMessage, + erroredMessage, + ]) + + expect(result?.message.info.id).toBe('1') + expect(result?.text).toBe('Test message text') + }) + + it('returns undefined when no assistant message has playable text', () => { + const result = getLatestPlayableAssistantMessage([ + createAssistantMessageWithoutText('1'), + ]) + + expect(result).toBeUndefined() + }) +}) diff --git a/frontend/src/hooks/useAutoPlayLastResponse.ts b/frontend/src/hooks/useAutoPlayLastResponse.ts index 7e71ed7c..f5a9c839 100644 --- a/frontend/src/hooks/useAutoPlayLastResponse.ts +++ b/frontend/src/hooks/useAutoPlayLastResponse.ts @@ -10,6 +10,23 @@ interface UseAutoPlayLastResponseParams { isStreamingResponse: boolean } +interface PlayableAssistantMessage { + message: MessageWithParts + text: string +} + +export function getAssistantText(message: MessageWithParts | undefined): string { + return (message?.parts ?? []).filter(p => p.type === 'text').map(p => p.text).join('\n\n') +} + +export function getLatestPlayableAssistantMessage(messages: MessageWithParts[] | undefined): PlayableAssistantMessage | undefined { + return messages + ?.filter(message => message.info.role === 'assistant') + .map(message => ({ message, text: getAssistantText(message) })) + .filter(({ text }) => text.trim().length > 0) + .at(-1) +} + function isMessageCompleted(message: MessageWithParts['info']): boolean { return message.role === 'assistant' && message.time.completed !== undefined } diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx index abf7e4ef..02ff2fc9 100644 --- a/frontend/src/pages/SessionDetail.tsx +++ b/frontend/src/pages/SessionDetail.tsx @@ -27,7 +27,7 @@ import { useAutoScroll } from "@/hooks/useAutoScroll"; import { useMobile } from "@/hooks/useMobile"; import { useVisualViewport } from "@/hooks/useVisualViewport"; import { useTTS } from "@/hooks/useTTS"; -import { useAutoPlayLastResponse } from "@/hooks/useAutoPlayLastResponse"; +import { getAssistantText, getLatestPlayableAssistantMessage, useAutoPlayLastResponse } from "@/hooks/useAutoPlayLastResponse"; import { useEffect, useRef, useCallback, useMemo } from "react"; import { MessageSkeleton } from "@/components/message/MessageSkeleton"; import { exportSession, downloadMarkdown } from "@/lib/exportSession"; @@ -161,7 +161,8 @@ export function SessionDetail() { const sessionStatus = useSessionStatusForSession(sessionId); const isSessionActive = sessionStatus.type === 'busy' || sessionStatus.type === 'compact' || sessionStatus.type === 'retry'; const lastAssistantMessage = messages?.filter(m => m.info.role === 'assistant').at(-1); - const lastAssistantText = (lastAssistantMessage?.parts ?? []).filter(p => p.type === 'text').map(p => p.text).join('\n\n') || ''; + const lastAssistantText = getAssistantText(lastAssistantMessage); + const latestPlayableAssistant = useMemo(() => getLatestPlayableAssistantMessage(messages), [messages]); const hasIncompleteMessages = lastAssistantMessage ? !('completed' in lastAssistantMessage.info.time && lastAssistantMessage.info.time.completed) : false; const isStreamingResponse = hasIncompleteMessages && isSessionActive; @@ -456,10 +457,10 @@ export function SessionDetail() { >
- {ttsEnabled && !hasPromptContent && !isSessionActive && lastAssistantMessage && lastAssistantText && ( + {ttsEnabled && !hasPromptContent && !isSessionActive && latestPlayableAssistant && ( )} {hasPromptContent && (