Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 55 additions & 1 deletion src/web-ui/src/flow_chat/components/btw/BtwSessionPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -154,6 +157,22 @@ export const BtwSessionPanel: React.FC<BtwSessionPanelProps> = ({
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);
}, []);
Expand Down Expand Up @@ -372,7 +391,29 @@ export const BtwSessionPanel: React.FC<BtwSessionPanelProps> = ({

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);
Expand Down Expand Up @@ -1060,6 +1101,19 @@ export const BtwSessionPanel: React.FC<BtwSessionPanelProps> = ({
</div>
</div>

<PresenceBoundary active={activePermissionPanelSnapshot != null}>
{renderedPermissionPanelSnapshot ? (
<PermissionRequestPanel
key={`${renderedPermissionPanelSnapshot.batch.sessionId}:${renderedPermissionPanelSnapshot.batch.roundId}`}
requests={renderedPermissionPanelSnapshot.batch.requests}
totalPendingCount={renderedPermissionPanelSnapshot.totalPendingCount}
visible={activePermissionPanelSnapshot != null}
onRespond={renderedPermissionPanelSnapshot.onRespond}
onRespondBatch={renderedPermissionPanelSnapshot.onRespondBatch}
/>
) : null}
</PresenceBoundary>

<div
ref={scrollContainerRef}
tabIndex={-1}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -468,15 +468,16 @@ export const ModernFlowChatContainer: React.FC<ModernFlowChatContainerProps> = (

const {
requests: permissionRequests,
activeBatch: activePermissionBatch,
ownedRequests: ownedPermissionRequests,
ownedActiveBatch: activePermissionBatch,
respond: respondPermission,
respondBatch: respondPermissionBatch,
} = usePermissionRequests(activeSession?.sessionId);
const activePermissionPanelSnapshot = activeSession && activePermissionBatch
? {
ownerSessionId: activeSession.sessionId,
batch: activePermissionBatch,
totalPendingCount: permissionRequests.length,
totalPendingCount: ownedPermissionRequests.length,
aboveChatInput: permissionPanelAboveChatInput,
onRespond: respondPermission,
onRespondBatch: respondPermissionBatch,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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];

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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;

Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
};
}