diff --git a/frontend/src/contexts/EventContext.test.tsx b/frontend/src/contexts/EventContext.test.tsx
index 9dc50a36..466b770b 100644
--- a/frontend/src/contexts/EventContext.test.tsx
+++ b/frontend/src/contexts/EventContext.test.tsx
@@ -94,7 +94,7 @@ const pendingPermission: PermissionRequest = {
}
function Harness() {
- const { current, pendingCount, syncForSession, navigateToCurrent, reject, reply } = useQuestions()
+ const { current, pendingCount, syncForSession, navigateToCurrent, reject, reply, getForSession } = useQuestions()
const permissions = usePermissions()
const permissionForCall = permissions.getForCallID('call-1', 'session-1')
const location = useLocation()
@@ -103,6 +103,9 @@ function Harness() {
{pendingCount}
{current?.id ?? 'none'}
+
{getForSession('session-1')?.id ?? 'none'}
+
{getForSession('session-2')?.id ?? 'none'}
+
{getForSession('session-unknown')?.id ?? 'none'}
{permissions.pendingCount}
{permissions.current?.id ?? 'none'}
{permissionForCall?.id ?? 'none'}
@@ -239,6 +242,23 @@ describe('EventProvider questions', () => {
})
})
+ it('resolves pending questions per session independently of the global current', async () => {
+ mocks.listPendingQuestions.mockResolvedValue([pendingQuestion, secondPendingQuestion])
+
+ render(
, { wrapper: createWrapper() })
+
+ await userEvent.click(screen.getByRole('button', { name: 'Sync' }))
+
+ await waitFor(() => {
+ expect(screen.getByTestId('count')).toHaveTextContent('2')
+ })
+
+ expect(screen.getByTestId('current')).toHaveTextContent('question-1')
+ expect(screen.getByTestId('for-session-1')).toHaveTextContent('question-1')
+ expect(screen.getByTestId('for-session-2')).toHaveTextContent('question-2')
+ expect(screen.getByTestId('for-session-unknown')).toHaveTextContent('none')
+ })
+
it('reconciles stale pending questions after reconnect', async () => {
mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }])
mocks.listPendingQuestions
diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx
index 638cc67f..fb266b75 100644
--- a/frontend/src/contexts/EventContext.tsx
+++ b/frontend/src/contexts/EventContext.tsx
@@ -158,6 +158,7 @@ interface EventContextValue {
reject: (requestID: string) => Promise
dismiss: (requestID: string, sessionID?: string) => void
getForCallID: (callID: string, sessionID: string) => QuestionRequest | null
+ getForSession: (sessionID: string) => QuestionRequest | null
hasForSession: (sessionID: string) => boolean
navigateToCurrent: () => void
syncForSession: (directory: string, sessionID: string) => Promise
@@ -404,6 +405,10 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
return (permissionsBySession[sessionID]?.length ?? 0) > 0
}, [permissionsBySession])
+ const getQuestionForSession = useCallback((sessionID: string): QuestionRequest | null => {
+ return questionsBySession[sessionID]?.[0] ?? null
+ }, [questionsBySession])
+
const hasQuestionsForSession = useCallback((sessionID: string): boolean => {
return (questionsBySession[sessionID]?.length ?? 0) > 0
}, [questionsBySession])
@@ -597,6 +602,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
reject: rejectQuestion,
dismiss: removeQuestion,
getForCallID: getQuestionForCallID,
+ getForSession: getQuestionForSession,
hasForSession: hasQuestionsForSession,
navigateToCurrent: navigateToCurrentQuestion,
syncForSession: syncQuestionsForSession,
@@ -622,6 +628,7 @@ export function EventProvider({ children }: { children: React.ReactNode }) {
rejectQuestion,
removeQuestion,
getQuestionForCallID,
+ getQuestionForSession,
hasQuestionsForSession,
navigateToCurrentQuestion,
syncQuestionsForSession,
diff --git a/frontend/src/pages/SessionDetail.tsx b/frontend/src/pages/SessionDetail.tsx
index c447cab3..187609f6 100644
--- a/frontend/src/pages/SessionDetail.tsx
+++ b/frontend/src/pages/SessionDetail.tsx
@@ -173,7 +173,8 @@ export function SessionDetail() {
const { isEnabled: ttsEnabled } = useTTS();
const sessionStatus = useSessionStatusForSession(sessionId);
const { syncForSession: syncPermissionsForSession } = usePermissions();
- const { current: currentQuestion, reply: replyToQuestion, reject: rejectQuestion, syncForSession: syncQuestionsForSession } = useQuestions();
+ const { getForSession: getQuestionForSession, reply: replyToQuestion, reject: rejectQuestion, syncForSession: syncQuestionsForSession } = useQuestions();
+ const currentQuestion = sessionId ? getQuestionForSession(sessionId) : null;
const lastAssistantMessage = messages?.filter(m => m.info.role === 'assistant').at(-1);
const lastAssistantText = getAssistantText(lastAssistantMessage);
@@ -568,7 +569,7 @@ export function SessionDetail() {
onDismiss={() => rejectQuestion(minimizedQuestion.id)}
/>
)}
- {!minimizedQuestion && currentQuestion && currentQuestion.sessionID === sessionId && (
+ {!minimizedQuestion && currentQuestion && (
{
})
mocks.useQuestions.mockReturnValue({
current: null,
+ getForSession: vi.fn(() => null),
pendingCount: 0,
hasQuestionsForSession: vi.fn(() => false),
reply: vi.fn(),
diff --git a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx
index eeb572ab..fc9bc42e 100644
--- a/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx
+++ b/frontend/src/pages/__tests__/SessionDetail.polling.test.tsx
@@ -164,6 +164,7 @@ describe('SessionDetail pending-actions polling gating', () => {
})
mocks.useQuestions.mockReturnValue({
current: null,
+ getForSession: vi.fn(() => null),
pendingCount: 0,
hasQuestionsForSession: vi.fn(() => false),
reply: vi.fn(),
diff --git a/frontend/src/pages/__tests__/SessionDetail.question-prompt.test.tsx b/frontend/src/pages/__tests__/SessionDetail.question-prompt.test.tsx
new file mode 100644
index 00000000..09233b6c
--- /dev/null
+++ b/frontend/src/pages/__tests__/SessionDetail.question-prompt.test.tsx
@@ -0,0 +1,278 @@
+import { describe, it, expect, vi, beforeEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { MemoryRouter, Route, Routes } from 'react-router-dom'
+import type { QuestionRequest } from '@/api/types'
+import { SessionDetail } from '../SessionDetail'
+
+const mocks = vi.hoisted(() => ({
+ useSession: vi.fn(),
+ useMessages: vi.fn(),
+ useSSE: vi.fn(),
+ useRepoActivity: vi.fn(),
+ usePermissions: vi.fn(),
+ useQuestions: vi.fn(),
+ useSSEHealth: vi.fn(),
+ useConfig: vi.fn(),
+ useOpenCodeClient: vi.fn(),
+ useMobile: vi.fn(),
+ useAutoScroll: vi.fn(),
+ useDialogParam: vi.fn(),
+ useSidebarAction: vi.fn(),
+ useSessionStatusForSession: vi.fn(),
+}))
+
+vi.mock('@/config', () => ({
+ OPENCODE_API_ENDPOINT: 'http://localhost:5551/api/opencode',
+ API_BASE_URL: 'http://localhost:5551',
+ SERVER_PORT: 5003,
+ OPENCODE_PORT: 5551,
+ FILE_LIMITS: {},
+ DEFAULTS: {},
+ ALLOWED_MIME_TYPES: [],
+ GIT_PROVIDERS: [],
+}))
+
+vi.mock('@/hooks/useOpenCode', () => ({
+ useSession: mocks.useSession,
+ useAbortSession: vi.fn(() => ({ mutate: vi.fn() })),
+ useUpdateSession: vi.fn(() => ({ mutate: vi.fn() })),
+ useCreateSession: vi.fn(() => ({ mutateAsync: vi.fn() })),
+ useMessages: mocks.useMessages,
+ useConfig: mocks.useConfig,
+ useSendPrompt: vi.fn(() => ({ mutate: vi.fn() })),
+ useSendShell: vi.fn(() => ({ mutate: vi.fn() })),
+ useAgents: vi.fn(() => ({ data: [] })),
+ useOpenCodeClient: mocks.useOpenCodeClient,
+}))
+
+vi.mock('@/hooks/useModelSelection', () => ({
+ useModelSelection: vi.fn(() => ({ model: null, modelString: null })),
+}))
+
+vi.mock('@/hooks/useTTS', () => ({
+ useTTS: vi.fn(() => ({ isEnabled: false })),
+}))
+
+vi.mock('@/hooks/useSettings', () => ({
+ useSettings: vi.fn(() => ({
+ preferences: { expandToolCalls: false },
+ updateSettings: vi.fn(),
+ })),
+}))
+
+vi.mock('@/hooks/useSettingsDialog', () => ({
+ useSettingsDialog: vi.fn(() => ({ open: vi.fn() })),
+}))
+
+vi.mock('@/hooks/useMobile', () => ({
+ useMobile: mocks.useMobile,
+ useSwipeBack: vi.fn(() => ({ ref: vi.fn() })),
+}))
+
+vi.mock('@/hooks/useVisualViewport', () => ({
+ useVisualViewport: vi.fn(() => ({ keyboardHeight: 0 })),
+}))
+
+vi.mock('@/hooks/useKeyboardShortcuts', () => ({
+ useKeyboardShortcuts: vi.fn(() => ({ leaderActive: false })),
+}))
+
+vi.mock('@/hooks/useAutoScroll', () => ({
+ useAutoScroll: mocks.useAutoScroll,
+}))
+
+vi.mock('@/hooks/useDialogParam', () => ({
+ useDialogParam: vi.fn(() => [false, vi.fn()]),
+}))
+
+vi.mock('@/hooks/useSidebarAction', () => ({
+ useSidebarAction: vi.fn(() => {}),
+}))
+
+vi.mock('@/hooks/useAutoPlayLastResponse', () => ({
+ getAssistantText: vi.fn(() => ''),
+ getLatestPlayableAssistantMessage: vi.fn(() => null),
+ useAutoPlayLastResponse: vi.fn(() => {}),
+}))
+
+vi.mock('@/stores/uiStateStore', () => ({
+ useUIState: vi.fn((selector?: (state: Record) => unknown) =>
+ typeof selector === 'function'
+ ? selector({ isEditingMessage: false, setActivePromptFileBasePath: vi.fn() })
+ : false
+ ),
+}))
+
+vi.mock('@/stores/sessionStatusStore', () => ({
+ useSessionStatus: vi.fn(() => ({ setStatus: vi.fn() })),
+ useSessionStatusForSession: mocks.useSessionStatusForSession,
+}))
+
+vi.mock('@/hooks/useSSE', () => ({
+ useSSE: mocks.useSSE,
+}))
+
+vi.mock('@/hooks/useRepoActivity', () => ({
+ useRepoActivity: mocks.useRepoActivity,
+}))
+
+vi.mock('@/contexts/EventContext', async (importOriginal) => {
+ const actual = await importOriginal()
+ return {
+ ...(actual as object),
+ usePermissions: mocks.usePermissions,
+ useQuestions: mocks.useQuestions,
+ useSSEHealth: mocks.useSSEHealth,
+ }
+})
+
+vi.mock('@/api/repos', () => ({
+ getRepo: vi.fn(() => Promise.resolve({
+ id: 1,
+ repoUrl: 'https://github.com/test/repo',
+ localPath: '/test/repo',
+ sourcePath: null,
+ fullPath: '/test/repo',
+ branch: 'main',
+ currentBranch: 'main',
+ fullSlug: 'test/repo',
+ repoType: 'github' as const,
+ })),
+ initializeAssistantMode: vi.fn(() => Promise.resolve({ directory: '/test/repo' })),
+}))
+
+vi.mock('@/components/model/ModelSelectDialog', () => ({
+ ModelSelectDialog: vi.fn(() => null),
+}))
+
+vi.mock('@/components/session/SessionList', () => ({
+ SessionList: vi.fn(() => null),
+}))
+
+vi.mock('@/components/file-browser/FileBrowserSheet', () => ({
+ FileBrowserSheet: vi.fn(() => null),
+}))
+
+vi.mock('@/components/repo/RepoMcpDialog', () => ({
+ RepoMcpDialog: vi.fn(() => null),
+}))
+
+vi.mock('@/components/repo/ResetPermissionsDialog', () => ({
+ ResetPermissionsDialog: vi.fn(() => null),
+}))
+
+vi.mock('@/components/repo/RepoLspDialog', () => ({
+ RepoLspDialog: vi.fn(() => null),
+}))
+
+vi.mock('@/components/repo/RepoSkillsDialog', () => ({
+ RepoSkillsDialog: vi.fn(() => null),
+}))
+
+vi.mock('@/components/source-control', () => ({
+ SourceControlPanel: vi.fn(() => null),
+}))
+
+vi.mock('@/components/session/QuestionPrompt', () => ({
+ QuestionPrompt: ({ question }: { question: QuestionRequest }) => (
+ {question.id}
+ ),
+}))
+
+vi.mock('@/components/session/MinimizedQuestionIndicator', () => ({
+ MinimizedQuestionIndicator: vi.fn(() => null),
+}))
+
+vi.mock('@/components/notifications/PendingActionsGroup', () => ({
+ PendingActionsGroup: vi.fn(() => null),
+}))
+
+vi.mock('@/components/message/PromptInput', () => ({
+ PromptInput: vi.fn(() => MockedPromptInput
),
+}))
+
+const VIEWED_SESSION_ID = 'viewed-session'
+
+function createQuestion(id: string, sessionID: string): QuestionRequest {
+ return {
+ id,
+ sessionID,
+ questions: [
+ {
+ question: 'Continue?',
+ header: 'Confirm',
+ options: [{ label: 'Yes', description: 'Continue' }],
+ multiple: false,
+ },
+ ],
+ }
+}
+
+const viewedSessionQuestion = createQuestion('question-viewed', VIEWED_SESSION_ID)
+const otherSessionQuestion = createQuestion('question-other', 'other-session')
+
+describe('SessionDetail question prompt session scoping', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+
+ mocks.useSession.mockReturnValue({ data: undefined, isLoading: false })
+ mocks.useMessages.mockReturnValue({ data: [], isLoading: false })
+ mocks.useSSE.mockReturnValue({ isConnected: true, isReconnecting: false })
+ mocks.useRepoActivity.mockReturnValue(undefined)
+ mocks.usePermissions.mockReturnValue({
+ pendingCount: 0,
+ syncForSession: vi.fn(),
+ })
+ mocks.useSSEHealth.mockReturnValue({ isHealthy: true })
+ mocks.useConfig.mockReturnValue({ data: undefined, isLoading: false })
+ mocks.useOpenCodeClient.mockReturnValue({})
+ mocks.useMobile.mockReturnValue(false)
+ mocks.useAutoScroll.mockReturnValue({ scrollToBottom: vi.fn() })
+ mocks.useDialogParam.mockReturnValue([false, vi.fn()])
+ mocks.useSidebarAction.mockReturnValue(undefined)
+ mocks.useSessionStatusForSession.mockReturnValue({ type: 'idle' })
+ })
+
+ const renderWithQuestions = (questionsBySession: Record, current: QuestionRequest | null) => {
+ mocks.useQuestions.mockReturnValue({
+ current,
+ getForSession: vi.fn((sessionID: string) => questionsBySession[sessionID] ?? null),
+ pendingCount: Object.keys(questionsBySession).length,
+ reply: vi.fn(),
+ reject: vi.fn(),
+ syncForSession: vi.fn(),
+ })
+
+ return render(
+
+
+
+ } />
+
+
+
+ )
+ }
+
+ it('renders the viewed session question when another session owns the globally current question', async () => {
+ renderWithQuestions(
+ {
+ [VIEWED_SESSION_ID]: viewedSessionQuestion,
+ 'other-session': otherSessionQuestion,
+ },
+ otherSessionQuestion
+ )
+
+ await waitFor(() => {
+ expect(screen.getByTestId('question-prompt')).toHaveTextContent('question-viewed')
+ })
+ })
+
+ it('renders no question prompt when only another session has a pending question', async () => {
+ renderWithQuestions({ 'other-session': otherSessionQuestion }, otherSessionQuestion)
+
+ await waitFor(() => expect(screen.getByText('MockedPromptInput')).toBeInTheDocument())
+ expect(screen.queryByTestId('question-prompt')).not.toBeInTheDocument()
+ })
+})
diff --git a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx
index f03d6455..c6169e1e 100644
--- a/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx
+++ b/frontend/src/pages/__tests__/SessionDetail.scroll-floating.test.tsx
@@ -214,6 +214,7 @@ describe('SessionDetail scroll floating button', () => {
})
mocks.useQuestions.mockReturnValue({
current: null,
+ getForSession: vi.fn(() => null),
pendingCount: 0,
hasQuestionsForSession: vi.fn(() => false),
reply: vi.fn(),
diff --git a/frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx b/frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx
index d5f098ec..bb5bab03 100644
--- a/frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx
+++ b/frontend/src/pages/__tests__/SessionDetail.todo-header.test.tsx
@@ -197,6 +197,7 @@ describe('SessionDetail todo-header integration', () => {
})
mocks.useQuestions.mockReturnValue({
current: null,
+ getForSession: vi.fn(() => null),
pendingCount: 0,
hasQuestionsForSession: vi.fn(() => false),
reply: vi.fn(),