diff --git a/backend/test/services/notification-format.test.ts b/backend/test/services/notification-format.test.ts index c83e30f7e..e7c72a450 100644 --- a/backend/test/services/notification-format.test.ts +++ b/backend/test/services/notification-format.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect } from 'vitest' import { getPermissionLabel, getPermissionDetail, + getPermissionAlwaysScope, getFormText, getGoalOutcomeTitle, getGoalStopReasonLabel, @@ -94,6 +95,42 @@ describe('getPermissionDetail', () => { it('returns empty primary when no detail is available', () => { expect(getPermissionDetail({ action: 'shell' }).primary).toBe('') }) + it('labels shell resources as commands', () => { + expect(getPermissionDetail({ action: 'shell', resources: ['ls'] }).label).toBe('Command') + expect(getPermissionDetail({ action: 'shell', resources: ['ls', 'pwd'] }).label).toBe('Commands') + }) + it('labels search patterns', () => { + expect(getPermissionDetail({ action: 'grep', resources: ['TODO'] }).label).toBe('Pattern') + }) + it('labels paths for read actions', () => { + expect(getPermissionDetail({ action: 'read', resources: ['/a', '/b'] }).label).toBe('Paths') + }) + it('labels edit and webfetch details', () => { + expect(getPermissionDetail({ action: 'edit', metadata: { filepath: 'a.ts' } }).label).toBe('File') + expect(getPermissionDetail({ action: 'webfetch', metadata: { url: 'https://example.com' } }).label).toBe('URL') + }) + it('falls back to a generic resource label for unknown actions', () => { + expect(getPermissionDetail({ action: 'frobnicate', resources: ['a'] }).label).toBe('Resource') + }) +}) + +describe('getPermissionAlwaysScope', () => { + it('returns null when no save patterns are present', () => { + expect(getPermissionAlwaysScope({ action: 'shell' })).toBeNull() + expect(getPermissionAlwaysScope({ action: 'shell', save: [] })).toBeNull() + }) + it('describes a wildcard scope', () => { + expect(getPermissionAlwaysScope({ action: 'shell', save: ['*'] })).toEqual({ + summary: 'This will always allow shell for this project.', + patterns: [], + }) + }) + it('lists the saved patterns', () => { + expect(getPermissionAlwaysScope({ action: 'shell', save: ['printf *', 'echo *'] })).toEqual({ + summary: 'This will always allow the following patterns for this project.', + patterns: ['printf *', 'echo *'], + }) + }) }) describe('getFormText', () => { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3cd9d97bf..ba389943a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -36,6 +36,7 @@ import { useAuth } from '@/hooks/useAuth' import { useOpenCodeFailureToast } from '@/hooks/useOpenCodeFailureToast' import { useOpenCodeServerActions } from '@/hooks/useOpenCodeServerActions' import { useGlobalShortcutActions } from '@/hooks/useGlobalShortcutActions' +import { useSession } from '@/hooks/useOpenCode' import { RestartServerDialog } from '@/components/settings/RestartServerDialog' const queryClient = new QueryClient({ @@ -103,17 +104,29 @@ function PermissionDialogWrapper() { respond: replyToPermission, showDialog, setShowDialog, + navigateToCurrent, + viewedSessionId, + currentDirectory, + currentIsFromDifferentSession, } = usePermissions() + const { getRepoIdForSession } = useEventContext() + const { data: session } = useSession(currentPermission?.sessionID, currentDirectory ?? undefined) + + const canOpenCurrentSession = currentPermission !== null + && getRepoIdForSession(currentPermission.sessionID) !== null + && (currentIsFromDifferentSession || viewedSessionId === null) return ( ) } diff --git a/frontend/src/components/session/PermissionRequestDialog.test.tsx b/frontend/src/components/session/PermissionRequestDialog.test.tsx index 0fed0cff9..ac99e2b94 100644 --- a/frontend/src/components/session/PermissionRequestDialog.test.tsx +++ b/frontend/src/components/session/PermissionRequestDialog.test.tsx @@ -15,6 +15,7 @@ const permission: PermissionRequest = { sessionID: 'session-1', action: 'shell', resources: ['rm -rf node_modules'], + save: ['rm *'], metadata: {}, } @@ -29,9 +30,99 @@ describe('PermissionRequestDialog', () => { render() expect(screen.getByText('Run Command')).toBeInTheDocument() + expect(screen.getByText('Command')).toBeInTheDocument() expect(screen.getByText('rm -rf node_modules')).toBeInTheDocument() }) + it('labels multiple shell resources as commands', () => { + render( + , + ) + + expect(screen.getByText('Commands')).toBeInTheDocument() + expect(screen.getByText(/git status/)).toBeInTheDocument() + expect(screen.getByText(/git push origin main/)).toBeInTheDocument() + }) + + it('shows the request message when present', () => { + render( + , + ) + + expect(screen.getByText('This command modifies tracked files.')).toBeInTheDocument() + }) + + it('describes a wildcard always scope', () => { + render( + , + ) + + expect(screen.getByText('This will always allow shell for this project.')).toBeInTheDocument() + }) + + it('lists the patterns an always decision approves', () => { + render( + , + ) + + expect( + screen.getByText('This will always allow the following patterns for this project.'), + ).toBeInTheDocument() + expect(screen.getByText('printf *')).toBeInTheDocument() + expect(screen.getByText('echo *')).toBeInTheDocument() + }) + + it('omits the always option when no save patterns are provided', () => { + render( + , + ) + + expect(screen.queryByRole('button', { name: 'Allow Always' })).not.toBeInTheDocument() + expect(screen.queryByText(/This will always allow/)).not.toBeInTheDocument() + }) + + it('renders an open session control that triggers the handler', async () => { + const onOpenSession = vi.fn() + render( + , + ) + + await userEvent.click(screen.getByRole('button', { name: 'Open session' })) + + expect(onOpenSession).toHaveBeenCalledTimes(1) + }) + + it('omits the open session control without a handler', () => { + render() + + expect(screen.queryByRole('button', { name: 'Open session' })).not.toBeInTheDocument() + }) + it('replies with the selected decision for the permission request', async () => { const onRespond = vi.fn().mockResolvedValue(undefined) render() diff --git a/frontend/src/components/session/PermissionRequestDialog.tsx b/frontend/src/components/session/PermissionRequestDialog.tsx index 9389ab402..43b2670d4 100644 --- a/frontend/src/components/session/PermissionRequestDialog.tsx +++ b/frontend/src/components/session/PermissionRequestDialog.tsx @@ -11,7 +11,11 @@ import { Button } from '@/components/ui/button' import { Textarea } from '@/components/ui/textarea' import type { PermissionResponse } from '@/api/types' import type { PermissionRequest } from '@opencode-manager/shared/opencode' -import { getPermissionLabel, getPermissionDetail } from '@opencode-manager/shared/notifications' +import { + getPermissionLabel, + getPermissionDetail, + getPermissionAlwaysScope, +} from '@opencode-manager/shared/notifications' import { cn } from '@/lib/utils' import { showToast } from '@/lib/toast' @@ -21,6 +25,7 @@ interface PermissionRequestDialogProps { isFromDifferentSession?: boolean sessionTitle?: string repoDirectory?: string | null + onOpenSession?: () => void onRespond: ( permissionID: string, sessionID: string, @@ -37,6 +42,7 @@ export function PermissionRequestDialog({ isFromDifferentSession, sessionTitle, repoDirectory, + onOpenSession, onRespond, open: parentOpen, onOpenChange, @@ -69,6 +75,7 @@ export function PermissionRequestDialog({ const typeLabel = getPermissionLabel(permission.action) const details = getPermissionDetail(permission) + const alwaysScope = getPermissionAlwaysScope(permission) const hasMultiple = pendingCount > 1 const displaySessionName = sessionTitle || `Session ${permission.sessionID.slice(0, 8)}...` @@ -95,15 +102,24 @@ export function PermissionRequestDialog({ {typeLabel} - + + {permission.message && ( +

{permission.message}

+ )} + {details.primary && ( -
-
-                {details.primary}
-              
+
+ {details.label && ( + {details.label} + )} +
+
+                  {details.primary}
+                
+
)} - + {details.secondary && (
@@ -114,19 +130,32 @@ export function PermissionRequestDialog({
 
           
{repoDirectory && ( -
+
Repo: {repoDirectory.split('/').pop() ?? repoDirectory}
)} - {isFromDifferentSession ? ( -
- From another session: {displaySessionName} -
- ) : ( -
- Session: {displaySessionName} -
- )} +
+ {isFromDifferentSession ? ( +
+ From another session: {displaySessionName} +
+ ) : ( +
+ Session: {displaySessionName} +
+ )} + {onOpenSession && ( + + )} +
@@ -144,6 +173,19 @@ export function PermissionRequestDialog({ />
+ {alwaysScope && ( +
+

{alwaysScope.summary}

+ {alwaysScope.patterns.length > 0 && ( +
    + {alwaysScope.patterns.map((pattern) => ( +
  • {pattern}
  • + ))} +
+ )} +
+ )} + - + {alwaysScope && ( + + )} + @@ -149,9 +205,9 @@ function createDeferred() { return { promise, resolve } } -function createWrapper(queryClient = createTestQueryClient()) { +function createWrapper(queryClient = createTestQueryClient(), initialPath = '/') { return ({ children }: { children: ReactNode }) => ( - + {children} @@ -173,6 +229,7 @@ function CacheResolutionHarness({ sessionID }: { sessionID: string }) { describe('EventProvider permissions and forms', () => { beforeEach(() => { vi.clearAllMocks() + window.history.pushState({}, '', '/') mocks.listRepos.mockResolvedValue([]) mocks.listPendingPermissions.mockResolvedValue([]) mocks.listPendingForms.mockResolvedValue([]) @@ -570,6 +627,107 @@ describe('EventProvider permissions and forms', () => { }) }) + it('navigates an assistant-session permission to the assistant session path', async () => { + mocks.listRepos.mockResolvedValue([{ id: 0, fullPath: '/assistant' }]) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.listPendingPermissions).toHaveBeenCalledWith('/assistant')) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'permission.asked', data: pendingPermission, directory: '/assistant' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-current')).toHaveTextContent('permission-1') + }) + + await userEvent.click(screen.getByRole('button', { name: 'Navigate Permission' })) + + await waitFor(() => { + expect(screen.getByTestId('path')).toHaveTextContent('/repos/0/sessions/session-1') + expect(screen.getByTestId('search')).toHaveTextContent('?assistant=1') + }) + }) + + it('navigates a normal-repo permission without the assistant query', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + + render(, { wrapper: createWrapper() }) + + await waitFor(() => expect(mocks.listPendingPermissions).toHaveBeenCalledWith('/repo')) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'permission.asked', data: pendingPermission, directory: '/repo' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-current')).toHaveTextContent('permission-1') + }) + + await userEvent.click(screen.getByRole('button', { name: 'Navigate Permission' })) + + await waitFor(() => { + expect(screen.getByTestId('path')).toHaveTextContent('/repos/123/sessions/session-1') + }) + expect(screen.getByTestId('search').textContent).toBe('') + }) + + it('does not navigate when already on the target session path', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + window.history.pushState({}, '', '/repos/123/sessions/session-1') + + render(, { + wrapper: createWrapper(createTestQueryClient(), '/repos/123/sessions/session-1'), + }) + + await waitFor(() => expect(mocks.listPendingPermissions).toHaveBeenCalledWith('/repo')) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'permission.asked', data: pendingPermission, directory: '/repo' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-current')).toHaveTextContent('permission-1') + }) + + const keyBefore = screen.getByTestId('location-key').textContent + + await userEvent.click(screen.getByRole('button', { name: 'Navigate Permission' })) + + expect(screen.getByTestId('location-key')).toHaveTextContent(keyBefore ?? '') + expect(screen.getByTestId('path')).toHaveTextContent('/repos/123/sessions/session-1') + }) + + it('navigates an assistant-session form to the assistant session path', async () => { + mocks.listRepos.mockResolvedValue([{ id: 0, fullPath: '/repo' }]) + mocks.listPendingForms.mockResolvedValue([pendingForm]) + + render(, { wrapper: createWrapper() }) + + await userEvent.click(screen.getByRole('button', { name: 'Sync' })) + + await waitFor(() => { + expect(screen.getByTestId('current')).toHaveTextContent('form-1') + }) + + await userEvent.click(screen.getByRole('button', { name: 'Navigate' })) + + await waitFor(() => { + expect(screen.getByTestId('path')).toHaveTextContent('/repos/0/sessions/session-1') + expect(screen.getByTestId('search')).toHaveTextContent('?assistant=1') + }) + }) + it('clears a pending form after dismiss succeeds', async () => { mocks.listPendingForms.mockResolvedValue([pendingForm]) @@ -1039,6 +1197,309 @@ describe('EventProvider permissions and forms', () => { }) }) + it('prioritizes the viewed session permission over an earlier unrelated one', async () => { + render(, { + wrapper: createWrapper(createTestQueryClient(), '/repos/123/sessions/session-view'), + }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'permission.asked', data: otherSessionPermission, directory: '/repo' }) + onEvent({ type: 'permission.asked', data: viewedSessionPermission, directory: '/repo' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-count')).toHaveTextContent('2') + expect(screen.getByTestId('permission-current')).toHaveTextContent('permission-view') + }) + expect(screen.getByTestId('permission-different')).toHaveTextContent('false') + }) + + it('prefers a descendant session permission when viewing its parent', async () => { + act(() => { + useSessionStatus.getState().setSessionParent('child-1', 'parent-1') + }) + + render(, { + wrapper: createWrapper(createTestQueryClient(), '/repos/123/sessions/parent-1'), + }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'permission.asked', data: otherSessionPermission, directory: '/repo' }) + onEvent({ type: 'permission.asked', data: childSessionPermission, directory: '/repo' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-current')).toHaveTextContent('permission-child') + }) + expect(screen.getByTestId('permission-different')).toHaveTextContent('false') + }) + + it('falls back to the first pending permission when the viewed session has none', async () => { + render(, { + wrapper: createWrapper(createTestQueryClient(), '/repos/123/sessions/session-view'), + }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'permission.asked', data: otherSessionPermission, directory: '/repo' }) + onEvent({ type: 'permission.asked', data: pendingPermission, directory: '/repo' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-count')).toHaveTextContent('2') + expect(screen.getByTestId('permission-current')).toHaveTextContent('permission-other') + }) + expect(screen.getByTestId('permission-different')).toHaveTextContent('true') + }) + + it('resolves the current permission directory from the query cache', async () => { + mocks.listRepos.mockResolvedValue([{ id: 123, fullPath: '/repo' }]) + const queryClient = createTestQueryClient() + queryClient.setQueryData( + ['opencode', 'session', 'session-1', '/repo'], + { + id: 'session-1', + projectID: 'proj-1', + title: 'Fix permission dialog', + location: { directory: '/repo' }, + time: { created: 1000, updated: 1000 }, + }, + ) + + render(, { wrapper: createWrapper(queryClient) }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'permission.asked', data: pendingPermission, directory: '/repo' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-current')).toHaveTextContent('permission-1') + expect(screen.getByTestId('permission-directory')).toHaveTextContent('/repo') + }) + }) + + it('keeps the shown permission when a viewed-session permission arrives, then advances once it is answered', async () => { + render(, { + wrapper: createWrapper(createTestQueryClient(), '/repos/123/sessions/session-view'), + }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'permission.asked', data: otherSessionPermission, directory: '/repo' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-current')).toHaveTextContent('permission-other') + }) + + act(() => { + onEvent({ type: 'permission.asked', data: viewedSessionPermission, directory: '/repo' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-count')).toHaveTextContent('2') + }) + expect(screen.getByTestId('permission-current')).toHaveTextContent('permission-other') + + act(() => { + onEvent({ + type: 'permission.replied', + data: { sessionID: 'session-other', requestID: 'permission-other', reply: 'reject' }, + directory: '/repo', + }) + }) + + await waitFor(() => { + expect(screen.getByTestId('permission-count')).toHaveTextContent('1') + expect(screen.getByTestId('permission-current')).toHaveTextContent('permission-view') + }) + }) + + it('prioritizes the viewed session form over an earlier unrelated one', async () => { + render(, { + wrapper: createWrapper(createTestQueryClient(), '/repos/123/sessions/session-view'), + }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'form.created', data: { form: otherSessionForm }, directory: '/repo' }) + onEvent({ type: 'form.created', data: { form: viewedSessionForm }, directory: '/repo' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('2') + expect(screen.getByTestId('current')).toHaveTextContent('form-view') + }) + }) + + it('prefers a descendant session form when viewing its parent', async () => { + act(() => { + useSessionStatus.getState().setSessionParent('child-1', 'parent-1') + }) + + render(, { + wrapper: createWrapper(createTestQueryClient(), '/repos/123/sessions/parent-1'), + }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'form.created', data: { form: otherSessionForm }, directory: '/repo' }) + onEvent({ type: 'form.created', data: { form: childSessionForm }, directory: '/repo' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('current')).toHaveTextContent('form-child') + }) + }) + + it('falls back to the first pending form when the viewed session has none', async () => { + render(, { + wrapper: createWrapper(createTestQueryClient(), '/repos/123/sessions/session-view'), + }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'form.created', data: { form: otherSessionForm }, directory: '/repo' }) + onEvent({ type: 'form.created', data: { form: pendingForm }, directory: '/repo' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('2') + expect(screen.getByTestId('current')).toHaveTextContent('form-other') + }) + }) + + it('keeps the shown form when a viewed-session form arrives, then advances once it is answered', async () => { + render(, { + wrapper: createWrapper(createTestQueryClient(), '/repos/123/sessions/session-view'), + }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'form.created', data: { form: otherSessionForm }, directory: '/repo' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('current')).toHaveTextContent('form-other') + }) + + act(() => { + onEvent({ type: 'form.created', data: { form: viewedSessionForm }, directory: '/repo' }) + }) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('2') + }) + expect(screen.getByTestId('current')).toHaveTextContent('form-other') + + act(() => { + onEvent({ + type: 'form.cancelled', + data: { id: 'form-other', sessionID: 'session-other' }, + directory: '/repo', + }) + }) + + await waitFor(() => { + expect(screen.getByTestId('count')).toHaveTextContent('1') + expect(screen.getByTestId('current')).toHaveTextContent('form-view') + }) + }) + + it('resolves ancestry when a permission arrives for an unknown session', async () => { + mocks.getSession.mockImplementation((sessionID: string) => { + if (sessionID === 'child-1') return Promise.resolve({ id: 'child-1', parentID: 'parent-1' }) + if (sessionID === 'parent-1') return Promise.resolve({ id: 'parent-1' }) + return Promise.reject(new Error('Session not found')) + }) + + render(, { + wrapper: createWrapper(createTestQueryClient(), '/repos/123/sessions/parent-1'), + }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'permission.asked', data: childSessionPermission, directory: '/repo' }) + }) + + await waitFor(() => { + expect(mocks.getSession).toHaveBeenCalledWith('child-1') + expect(useSessionStatus.getState().parents.get('child-1')).toBe('parent-1') + }) + expect(screen.getByTestId('permission-current')).toHaveTextContent('permission-child') + expect(screen.getByTestId('permission-different')).toHaveTextContent('false') + }) + + it('resolves ancestry when a form arrives for an unknown session', async () => { + mocks.getSession.mockImplementation((sessionID: string) => { + if (sessionID === 'child-1') return Promise.resolve({ id: 'child-1', parentID: 'parent-1' }) + if (sessionID === 'parent-1') return Promise.resolve({ id: 'parent-1' }) + return Promise.reject(new Error('Session not found')) + }) + + render(, { + wrapper: createWrapper(createTestQueryClient(), '/repos/123/sessions/parent-1'), + }) + + await waitFor(() => expect(mocks.subscribeGlobalMonitor).toHaveBeenCalled()) + + const lastSubscribeCall = mocks.subscribeGlobalMonitor.mock.calls[mocks.subscribeGlobalMonitor.mock.calls.length - 1] + const onEvent = lastSubscribeCall[0].onEvent as (data: unknown) => void + + act(() => { + onEvent({ type: 'form.created', data: { form: childSessionForm }, directory: '/repo' }) + }) + + await waitFor(() => { + expect(mocks.getSession).toHaveBeenCalledWith('child-1') + expect(useSessionStatus.getState().parents.get('child-1')).toBe('parent-1') + }) + expect(screen.getByTestId('current')).toHaveTextContent('form-child') + }) + it('exposes sseHealth through context', async () => { mocks.getHealth.mockReturnValue({ isConnected: true, isHealthy: true, lastEventAt: Date.now(), isStalled: false }) mocks.subscribeGlobalMonitor.mockImplementation(({ onHealthChange }) => { diff --git a/frontend/src/contexts/EventContext.tsx b/frontend/src/contexts/EventContext.tsx index 307451829..60c2dd0e6 100644 --- a/frontend/src/contexts/EventContext.tsx +++ b/frontend/src/contexts/EventContext.tsx @@ -1,6 +1,6 @@ /* eslint-disable react-refresh/only-export-components */ import { createContext, useContext, useEffect, useState, useCallback, useMemo, useRef } from 'react' -import { useNavigate } from 'react-router-dom' +import { useLocation, useNavigate } from 'react-router-dom' import { useQueryClient, useQuery } from '@tanstack/react-query' import { cancelForm, @@ -19,6 +19,7 @@ import type { PermissionResponse, SSHHostKeyRequest, Repo } from '@/api/types' import { showToast } from '@/lib/toast' import { openCodeEventStream, type EventStreamHealthState } from '@/lib/opencode-event-stream' import { addToSessionKeyedState, removeFromSessionKeyedState } from '@/lib/sessionKeyedState' +import { getSessionPath, parseRepoRoute } from '@/lib/navigation' import { changeWalkthroughQueryKey } from '@/hooks/useChangeWalkthrough' import { busyStatusesFromActiveSessions, useSessionStatus } from '@/stores/sessionStatusStore' import { @@ -71,6 +72,53 @@ function repoMatchesDirectory(repo: Repo, directory?: string | null) { .some((path) => normalizeDirectory(path) === normalizedDirectory) } +function isSessionWithinView( + sessionID: string, + viewedSessionID: string, + parents: Map, +): boolean { + const visited = new Set() + let currentID: string | null = sessionID + + while (currentID !== null && !visited.has(currentID)) { + if (currentID === viewedSessionID) return true + visited.add(currentID) + currentID = parents.get(currentID) ?? null + } + + return false +} + +/** + * Pins the item currently on screen until it leaves `items`, then advances to the first item whose + * session is the viewed session or one of its descendants, falling back to the first item. + * The selection is derived during render so a newly arriving item never displaces the shown one. + */ +function usePinnedViewedItem( + items: T[], + viewedSessionId: string | null, + parents: Map, +): T | null { + const [pinnedId, setPinnedId] = useState(null) + + const selected = useMemo(() => { + if (pinnedId) { + const pinned = items.find((item) => item.id === pinnedId) + if (pinned) return pinned + } + + const forViewedSession = viewedSessionId + ? items.find((item) => isSessionWithinView(item.sessionID, viewedSessionId, parents)) ?? null + : null + return forViewedSession ?? items[0] ?? null + }, [items, viewedSessionId, parents, pinnedId]) + + const selectedId = selected?.id ?? null + if (selectedId !== pinnedId) setPinnedId(selectedId) + + return selected +} + function reconcileBySessionForDirectory( prev: Record, directory: string, @@ -104,6 +152,19 @@ interface EventContextValue { permissions: { current: PermissionRequest | null pendingCount: number + /** + * Session currently being viewed from the route, or null when no session route is active. + */ + viewedSessionId: string | null + /** + * Directory of the current permission's session, resolved from tracked events and the query cache. + */ + currentDirectory: string | null + /** + * True when a session is being viewed and the current permission belongs to neither that session + * nor one of its descendants. + */ + currentIsFromDifferentSession: boolean /** * Replies to a permission request and removes it from the queue. Resolves, after showing an * "expired" toast, when the server no longer knows the request (`PermissionNotFoundError`); @@ -169,6 +230,8 @@ const EventContext = createContext(null) export function EventProvider({ children }: { children: React.ReactNode }) { const queryClient = useQueryClient() const navigate = useNavigate() + const location = useLocation() + const sessionParents = useSessionStatus((state) => state.parents) const [sshHostKeyRequest, setSSHHostKeyRequest] = useState(null) const [sseHealth, setSseHealth] = useState(() => { @@ -217,52 +280,72 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const allPermissions = useMemo(() => Object.values(permissionsBySession).flat(), [permissionsBySession]) const allForms = useMemo(() => Object.values(formsBySession).flat(), [formsBySession]) - const currentPermission = allPermissions[0] ?? null - const currentForm = allForms[0] ?? null + const viewedSessionId = useMemo( + () => parseRepoRoute(location.pathname).sessionId, + [location.pathname], + ) + + const currentPermission = usePinnedViewedItem(allPermissions, viewedSessionId, sessionParents) + + const currentForm = usePinnedViewedItem(allForms, viewedSessionId, sessionParents) 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 findCachedSessionDirectory = useCallback((sessionID: string): string | null => { const cache = queryClient.getQueryCache() const queries = cache.getAll() + let directory: string | undefined + for (const query of queries) { const key = query.queryKey if (key[0] !== 'opencode' || key[1] !== 'session' || key[2] !== sessionID) continue const sessionData = query.state.data as { location?: { directory?: string } } | undefined - const directory = sessionData?.location?.directory ?? (typeof key[3] === 'string' ? key[3] : null) - if (directory) return directory + const resolvedDirectory = sessionData?.location?.directory + ?? (typeof key[3] === 'string' ? key[3] : undefined) + if (resolvedDirectory) { + directory = resolvedDirectory + break + } } - for (const query of queries) { - const key = query.queryKey - if (key[0] !== 'opencode' || key[1] !== 'sessions') continue + if (!directory) { + for (const query of queries) { + const key = query.queryKey + if (key[0] !== 'opencode' || key[1] !== 'sessions') continue - const sessionsData = query.state.data - if (!sessionsData || typeof sessionsData !== 'object' || !('pages' in sessionsData)) continue + const sessionsData = query.state.data + if (!sessionsData || typeof sessionsData !== 'object' || !('pages' in sessionsData)) continue - const pages = (sessionsData as { - pages?: Array<{ items?: Array<{ id: string; location?: { directory?: string } }> }> - }).pages - if (!Array.isArray(pages)) continue + const pages = (sessionsData as { + pages?: Array<{ items?: Array<{ id: string; location?: { directory?: string } }> }> + }).pages + if (!Array.isArray(pages)) continue - for (const page of pages) { - const found = page.items?.find(s => s.id === sessionID) - const directory = found?.location?.directory - if (directory) return directory + for (const page of pages) { + const found = page.items?.find(s => s.id === sessionID) + if (found?.location?.directory) { + directory = found.location.directory + break + } + } + if (directory) break } } - return null + return directory ?? null }, [queryClient]) + const findSessionDirectory = useCallback((sessionID: string): string | null => { + const remembered = sessionDirectoriesRef.current.get(sessionID) + if (remembered) return remembered + return findCachedSessionDirectory(sessionID) + }, [findCachedSessionDirectory]) + const getRepoIdForSession = useCallback((sessionID: string): number | null => { if (!repos) return null const directory = findSessionDirectory(sessionID) @@ -271,6 +354,15 @@ export function EventProvider({ children }: { children: React.ReactNode }) { return repo?.id ?? null }, [repos, findSessionDirectory]) + const currentPermissionDirectory = useMemo( + () => (currentPermission ? findSessionDirectory(currentPermission.sessionID) : null), + [currentPermission, findSessionDirectory], + ) + + const currentIsFromDifferentSession = viewedSessionId !== null + && currentPermission !== null + && !isSessionWithinView(currentPermission.sessionID, viewedSessionId, sessionParents) + const addPermission = useCallback((permission: PermissionRequest) => { addToSessionKeyedState(setPermissionsBySession, permission) }, []) @@ -374,27 +466,24 @@ export function EventProvider({ children }: { children: React.ReactNode }) { return (formsBySession[sessionID]?.length ?? 0) > 0 }, [formsBySession]) + const navigateToSession = useCallback((sessionID: string) => { + const repoId = getRepoIdForSession(sessionID) + if (repoId === null) return + const targetPath = getSessionPath(repoId, sessionID) + if (`${window.location.pathname}${window.location.search}` !== targetPath) { + navigate(targetPath) + } + }, [getRepoIdForSession, navigate]) + const navigateToCurrentForm = useCallback(() => { if (!currentForm) return - const repoId = getRepoIdForSession(currentForm.sessionID) - if (repoId) { - const targetPath = `/repos/${repoId}/sessions/${currentForm.sessionID}` - if (window.location.pathname !== targetPath) { - navigate(targetPath) - } - } - }, [currentForm, getRepoIdForSession, navigate]) + navigateToSession(currentForm.sessionID) + }, [currentForm, navigateToSession]) const navigateToCurrentPermission = useCallback(() => { if (!currentPermission) return - const repoId = getRepoIdForSession(currentPermission.sessionID) - if (repoId) { - const targetPath = `/repos/${repoId}/sessions/${currentPermission.sessionID}` - if (window.location.pathname !== targetPath) { - navigate(targetPath) - } - } - }, [currentPermission, getRepoIdForSession, navigate]) + navigateToSession(currentPermission.sessionID) + }, [currentPermission, navigateToSession]) const reconcilePendingActionsForDirectories = useCallback(async (directories: string[]) => { await Promise.all(directories.map(async (directory) => { @@ -557,6 +646,9 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const request = event.data rememberSessionDirectory(request.sessionID, event.directory) addPermission(request) + if (!useSessionStatus.getState().parents.has(request.sessionID)) { + void resolveSessionAncestry([request.sessionID]) + } break } case 'permission.replied': { @@ -568,6 +660,9 @@ export function EventProvider({ children }: { children: React.ReactNode }) { const form = event.data.form rememberSessionDirectory(form.sessionID, event.directory) addForm(form) + if (!useSessionStatus.getState().parents.has(form.sessionID)) { + void resolveSessionAncestry([form.sessionID]) + } break } case 'form.replied': @@ -766,6 +861,9 @@ export function EventProvider({ children }: { children: React.ReactNode }) { permissions: { current: currentPermission, pendingCount: allPermissions.length, + viewedSessionId, + currentDirectory: currentPermissionDirectory, + currentIsFromDifferentSession, respond: replyToPermission, dismiss: removePermission, getForToolCall: getPermissionForToolCall, @@ -793,6 +891,9 @@ export function EventProvider({ children }: { children: React.ReactNode }) { respondToSSHHostKey, currentPermission, allPermissions.length, + viewedSessionId, + currentPermissionDirectory, + currentIsFromDifferentSession, replyToPermission, removePermission, hasPermissionsForSession, diff --git a/shared/src/notifications/format.ts b/shared/src/notifications/format.ts index 379bb5a1c..910c391f7 100644 --- a/shared/src/notifications/format.ts +++ b/shared/src/notifications/format.ts @@ -75,11 +75,29 @@ interface PermissionLike { action?: unknown metadata?: unknown resources?: unknown + save?: unknown } export interface PermissionDetail { primary: string secondary?: string + label?: string +} + +const PERMISSION_RESOURCE_LABELS: Record = { + shell: { one: 'Command', many: 'Commands' }, + glob: { one: 'Pattern', many: 'Patterns' }, + grep: { one: 'Pattern', many: 'Patterns' }, + read: { one: 'Path', many: 'Paths' }, + list: { one: 'Path', many: 'Paths' }, + edit: { one: 'File', many: 'Files' }, + external_directory: { one: 'Path', many: 'Paths' }, + webfetch: { one: 'URL', many: 'URLs' }, +} + +function getPermissionResourceLabel(action: string, count: number): string { + const labels = PERMISSION_RESOURCE_LABELS[action] ?? { one: 'Resource', many: 'Resources' } + return count > 1 ? labels.many : labels.one } export function getPermissionDetail(input: PermissionLike): PermissionDetail { @@ -95,18 +113,37 @@ export function getPermissionDetail(input: PermissionLike): PermissionDetail { const filePath = str(first.file) ?? str(metadata.filepath) if (filePath) { const diff = str(first.patch) ?? str(metadata.diff) - return { primary: filePath, secondary: diff ? diff.slice(0, 500) + (diff.length > 500 ? '\n...' : '') : undefined } + return { + primary: filePath, + secondary: diff ? diff.slice(0, 500) + (diff.length > 500 ? '\n...' : '') : undefined, + label: getPermissionResourceLabel('edit', 1), + } } break } case 'webfetch': { const url = str(metadata.url) - if (url) return { primary: url } + if (url) return { primary: url, label: getPermissionResourceLabel('webfetch', 1) } break } } - return { primary: resources.join('\n') } + return { primary: resources.join('\n'), label: getPermissionResourceLabel(action, resources.length) } +} + +export interface PermissionAlwaysScope { + summary: string + patterns: string[] +} + +export function getPermissionAlwaysScope(input: PermissionLike): PermissionAlwaysScope | null { + const action = typeof input.action === 'string' ? input.action : '' + const save = Array.isArray(input.save) ? input.save.filter((item): item is string => typeof item === 'string') : [] + if (save.length === 0) return null + if (save.length === 1 && save[0] === '*') { + return { summary: `This will always allow ${action} for this project.`, patterns: [] } + } + return { summary: 'This will always allow the following patterns for this project.', patterns: save } } interface FormLike {