diff --git a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx index b71b4cfb41..3c43e16a92 100644 --- a/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx +++ b/src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx @@ -5,6 +5,9 @@ import {CornerUpLeft, Link2, Loader2, Square, Sparkles} from 'lucide-react'; import {FlowChatContext, FlowChatVolatileContext} from '../modern/FlowChatContext'; import {VirtualItemRenderer} from '../modern/VirtualItemRenderer'; import {RuntimeStatusSlot} from '../modern/RuntimeStatusSlot'; +import {PermissionRequestPanel} from '../modern/PermissionRequestPanel'; +import {pendingPermissionToolCallIdsForSession} from '../modern/permissionRequestRouting'; +import {usePermissionRequests} from '../modern/usePermissionRequests'; import {useExploreGroupState} from '../modern/useExploreGroupState'; import {ScrollToBottomButton} from '@/flow_chat'; import {flowChatStore} from '../../store/FlowChatStore'; @@ -154,6 +157,22 @@ export const BtwSessionPanel: React.FC = ({ const [actionBarHeight, setActionBarHeight] = useState(0); const shouldAutoScrollRef = useRef(true); + // BTW/review sessions render outside ModernFlowChatContainer, so they must + // own the same permission mailbox projection. Without this, a tool call can + // be waiting for runtime authorization while the embedded panel has no way + // to show or answer the request. + const { + requests: permissionRequests, + ownedRequests: ownedPermissionRequests, + ownedActiveBatch: activePermissionBatch, + respond: respondPermission, + respondBatch: respondPermissionBatch, + } = usePermissionRequests(childSessionId); + const pendingPermissionToolCallIds = useMemo( + () => pendingPermissionToolCallIdsForSession(permissionRequests, childSessionId), + [permissionRequests, childSessionId], + ); + useEffect(() => { return flowChatStore.subscribe(setFlowChatState); }, []); @@ -372,7 +391,29 @@ export const BtwSessionPanel: React.FC = ({ const volatileContextValue = useMemo(() => ({ exploreGroupStates, - }), [exploreGroupStates]); + pendingPermissionToolCallIds, + }), [exploreGroupStates, pendingPermissionToolCallIds]); + + const activePermissionPanelSnapshot = childSession && activePermissionBatch + ? { + ownerSessionId: childSession.sessionId, + batch: activePermissionBatch, + totalPendingCount: ownedPermissionRequests.length, + onRespond: respondPermission, + onRespondBatch: respondPermissionBatch, + } + : null; + const retainedPermissionPanelSnapshotRef = useRef(activePermissionPanelSnapshot); + let renderedPermissionPanelSnapshot = activePermissionPanelSnapshot; + if (activePermissionPanelSnapshot) { + retainedPermissionPanelSnapshotRef.current = activePermissionPanelSnapshot; + } else if ( + retainedPermissionPanelSnapshotRef.current?.ownerSessionId === childSessionId + ) { + renderedPermissionPanelSnapshot = retainedPermissionPanelSnapshotRef.current; + } else { + retainedPermissionPanelSnapshotRef.current = null; + } const lastDialogTurn = childSession?.dialogTurns[childSession.dialogTurns.length - 1]; const isTurnProcessing = isActiveReviewTurnStatus(lastDialogTurn?.status); @@ -1060,6 +1101,19 @@ export const BtwSessionPanel: React.FC = ({ + + {renderedPermissionPanelSnapshot ? ( + + ) : null} + +
= ( const { requests: permissionRequests, - activeBatch: activePermissionBatch, + ownedRequests: ownedPermissionRequests, + ownedActiveBatch: activePermissionBatch, respond: respondPermission, respondBatch: respondPermissionBatch, } = usePermissionRequests(activeSession?.sessionId); @@ -476,7 +477,7 @@ export const ModernFlowChatContainer: React.FC = ( ? { ownerSessionId: activeSession.sessionId, batch: activePermissionBatch, - totalPendingCount: permissionRequests.length, + totalPendingCount: ownedPermissionRequests.length, aboveChatInput: permissionPanelAboveChatInput, onRespond: respondPermission, onRespondBatch: respondPermissionBatch, diff --git a/src/web-ui/src/flow_chat/components/modern/permissionRequestRouting.test.ts b/src/web-ui/src/flow_chat/components/modern/permissionRequestRouting.test.ts index 21302b6f3b..81649f6474 100644 --- a/src/web-ui/src/flow_chat/components/modern/permissionRequestRouting.test.ts +++ b/src/web-ui/src/flow_chat/components/modern/permissionRequestRouting.test.ts @@ -3,8 +3,12 @@ import type { PermissionRequest } from '@/infrastructure/api/service-api/AgentAP import { applyPermissionRequestEvent, pendingPermissionToolCallIdsForSession, + permissionRequestIsOwnedBySession, + permissionRequestOwnerSessionId, reconcilePermissionRequestSnapshot, + selectActivePermissionBatchOwnedBySession, selectPermissionRequestsForSession, + selectPermissionRequestsOwnedBySession, selectActivePermissionBatch, sortPermissionRequests, } from './permissionRequestRouting'; @@ -63,6 +67,36 @@ describe('permission request routing', () => { expect(selectPermissionRequestsForSession(requests, undefined)).toEqual([]); }); + it('assigns delegated requests to exactly one display owner', () => { + const requests = [parentRequest, childRequest, unrelatedRequest]; + + expect(permissionRequestOwnerSessionId(parentRequest)).toBe('parent-session'); + expect(permissionRequestOwnerSessionId(childRequest)).toBe('parent-session'); + expect(permissionRequestIsOwnedBySession(childRequest, 'parent-session')).toBe(true); + expect(permissionRequestIsOwnedBySession(childRequest, 'child-session')).toBe(false); + + expect(selectPermissionRequestsOwnedBySession(requests, 'parent-session')).toEqual([ + parentRequest, + childRequest, + ]); + expect(selectPermissionRequestsOwnedBySession(requests, 'child-session')).toEqual([]); + expect(selectPermissionRequestsOwnedBySession( + [request('review-child', 'review-child', 'review-tool')], + 'review-child', + )).toEqual([ + request('review-child', 'review-child', 'review-tool'), + ]); + }); + + it('does not expose a delegated child batch to the child surface', () => { + expect(selectActivePermissionBatchOwnedBySession([childRequest], 'child-session')).toBeUndefined(); + expect(selectActivePermissionBatchOwnedBySession([childRequest], 'parent-session')).toEqual({ + sessionId: 'child-session', + roundId: 'round-child', + requests: [childRequest], + }); + }); + it('maps delegated requests to the parent Task card and direct requests to their tool card', () => { const requests = [parentRequest, childRequest, unrelatedRequest]; diff --git a/src/web-ui/src/flow_chat/components/modern/permissionRequestRouting.ts b/src/web-ui/src/flow_chat/components/modern/permissionRequestRouting.ts index a82b5218f1..4a9973cc13 100644 --- a/src/web-ui/src/flow_chat/components/modern/permissionRequestRouting.ts +++ b/src/web-ui/src/flow_chat/components/modern/permissionRequestRouting.ts @@ -11,6 +11,23 @@ export function permissionRequestBelongsToSession( return request.sessionId === sessionId || request.delegation?.parentSessionId === sessionId; } +/** + * Return the single session surface that owns the user interaction for a + * permission request. Delegated subagent requests are surfaced by their + * parent task; direct requests stay with the session that emitted them. + */ +export function permissionRequestOwnerSessionId(request: PermissionRequest): string { + return request.delegation?.parentSessionId ?? request.sessionId; +} + +export function permissionRequestIsOwnedBySession( + request: PermissionRequest, + sessionId?: string, +): boolean { + if (!sessionId) return false; + return permissionRequestOwnerSessionId(request) === sessionId; +} + export function selectPermissionRequestsForSession( requests: readonly PermissionRequest[], sessionId?: string, @@ -20,17 +37,30 @@ export function selectPermissionRequestsForSession( ); } +/** + * Select requests for the one UI surface that is allowed to present and + * answer them. This is intentionally separate from + * `selectPermissionRequestsForSession`, which also projects delegated child + * requests into the parent session for task-card state and history context. + */ +export function selectPermissionRequestsOwnedBySession( + requests: readonly PermissionRequest[], + sessionId?: string, +): PermissionRequest[] { + return sortPermissionRequests( + requests.filter((request) => permissionRequestIsOwnedBySession(request, sessionId)), + ); +} + export interface PermissionRequestBatch { sessionId: string; roundId: string; requests: PermissionRequest[]; } -export function selectActivePermissionBatch( - requests: readonly PermissionRequest[], - sessionId?: string, +function selectActivePermissionBatchFromRequests( + routed: readonly PermissionRequest[], ): PermissionRequestBatch | undefined { - const routed = selectPermissionRequestsForSession(requests, sessionId); const first = routed[0]; if (!first) return undefined; @@ -44,6 +74,24 @@ export function selectActivePermissionBatch( }; } +export function selectActivePermissionBatch( + requests: readonly PermissionRequest[], + sessionId?: string, +): PermissionRequestBatch | undefined { + return selectActivePermissionBatchFromRequests( + selectPermissionRequestsForSession(requests, sessionId), + ); +} + +export function selectActivePermissionBatchOwnedBySession( + requests: readonly PermissionRequest[], + sessionId?: string, +): PermissionRequestBatch | undefined { + return selectActivePermissionBatchFromRequests( + selectPermissionRequestsOwnedBySession(requests, sessionId), + ); +} + /** * Keep permission requests in arrival order across rounds, while preserving * the model-provided order inside each round. The first-seen batch position is diff --git a/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.test.tsx b/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.test.tsx index b77103f3ce..5732b0b700 100644 --- a/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.test.tsx +++ b/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.test.tsx @@ -163,6 +163,7 @@ describe('usePermissionRequests', () => { emit({ event: 'asked', request: unrelated }); expect(controller?.requests.map((item) => item.requestId)).toEqual(['child-a', 'child-b']); + expect(controller?.ownedRequests.map((item) => item.requestId)).toEqual(['child-a', 'child-b']); emit({ event: 'asked', request: { ...childA, resources: ['src/lib.rs'] } }); expect(controller?.requests).toHaveLength(2); @@ -186,6 +187,27 @@ describe('usePermissionRequests', () => { expect(controller?.requests).toEqual([]); }); + it('keeps delegated requests actionable only from the parent surface', async () => { + const child = request('delegated-child', 'child-session', 'parent-session'); + + await renderHarness(root, 'child-session', (next) => { + controller = next; + }); + emit({ event: 'asked', request: child }); + + expect(controller?.requests.map((item) => item.requestId)).toEqual(['delegated-child']); + expect(controller?.ownedRequests).toEqual([]); + expect(controller?.ownedActiveBatch).toBeUndefined(); + + await renderHarness(root, 'parent-session', (next) => { + controller = next; + }); + expect(controller?.ownedRequests.map((item) => item.requestId)).toEqual(['delegated-child']); + expect(controller?.ownedActiveBatch?.requests.map((item) => item.requestId)).toEqual([ + 'delegated-child', + ]); + }); + it('removes a request only after a successful explicit response', async () => { await renderHarness(root, 'session-1', (next) => { controller = next; diff --git a/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.ts b/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.ts index eda7911e0a..10df041ff8 100644 --- a/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.ts +++ b/src/web-ui/src/flow_chat/components/modern/usePermissionRequests.ts @@ -5,7 +5,9 @@ import { } from '@/infrastructure/api/service-api/AgentAPI'; import { selectActivePermissionBatch, + selectActivePermissionBatchOwnedBySession, selectPermissionRequestsForSession, + selectPermissionRequestsOwnedBySession, } from './permissionRequestRouting'; import { FlowChatStore } from '../../store/FlowChatStore'; import { driverForSession } from '../../session-drivers/registry'; @@ -86,6 +88,24 @@ export function usePermissionRequests(sessionId?: string) { () => selectActivePermissionBatch(effectiveRequests, sessionId), [effectiveRequests, sessionId], ); + const ownedRequests = useMemo( + () => selectPermissionRequestsOwnedBySession(effectiveRequests, sessionId), + [effectiveRequests, sessionId], + ); + const ownedActiveBatch = useMemo( + () => selectActivePermissionBatchOwnedBySession(effectiveRequests, sessionId), + [effectiveRequests, sessionId], + ); - return { requests: sessionRequests, activeBatch, respond, respondBatch }; + // Keep the broad projection for transcript/task-card state, while exposing + // the owner-only projection for permission UI so one request has one + // actionable surface. + return { + requests: sessionRequests, + activeBatch, + ownedRequests, + ownedActiveBatch, + respond, + respondBatch, + }; }