From c6c6d933e27b1eb1c132f349937d8ca38ef03887 Mon Sep 17 00:00:00 2001
From: Govinda Vashishtha <57435703+govindavashishtha@users.noreply.github.com>
Date: Thu, 27 Aug 2026 19:03:28 +0530
Subject: [PATCH 1/3] Add Approval Navigation Banner and Related Functionality
---
.changeset/pre/approval-nav-banner.md | 5 +
.../src/atoms/ApprovalNavBanner.tsx | 87 +++++++++++
.../trueforge-ui/src/atoms/ToolCallCard.tsx | 17 +-
.../src/containers/ApprovalNavContainer.tsx | 24 +++
.../src/containers/ComposerContainer.tsx | 82 +++++++---
.../src/containers/ThreadContainer.tsx | 77 ++++-----
.../src/containers/ToolApprovalContainer.tsx | 3 +
.../src/containers/ToolCallContainer.tsx | 8 +-
.../src/containers/approvalFocus.tsx | 147 ++++++++++++++++++
.../trueforge-ui/src/hooks/useApprovalNav.ts | 80 ++++++++++
.../src/hooks/useComposerPauseView.ts | 13 +-
packages/trueforge-ui/src/index.ts | 7 +
packages/trueforge-ui/src/styles.css | 41 +++++
.../trueforge-ui/src/theme/defaultSlots.ts | 2 +
.../src/utils/findApprovalAncestors.ts | 64 ++++++++
.../test/atoms/ApprovalNavBanner.test.tsx | 100 ++++++++++++
.../containers/ComposerContainer.test.tsx | 38 +++++
.../test/containers/Thread.test.tsx | 1 +
.../test/containers/TrueForgeUI.test.tsx | 1 +
.../test/hooks/useComposerPauseView.test.tsx | 24 +++
.../trueforge-ui/test/publicUiExports.test.ts | 5 +
.../routing/withRouter.integration.test.tsx | 1 +
.../test/utils/findApprovalAncestors.test.ts | 83 ++++++++++
23 files changed, 846 insertions(+), 64 deletions(-)
create mode 100644 .changeset/pre/approval-nav-banner.md
create mode 100644 packages/trueforge-ui/src/atoms/ApprovalNavBanner.tsx
create mode 100644 packages/trueforge-ui/src/containers/ApprovalNavContainer.tsx
create mode 100644 packages/trueforge-ui/src/containers/approvalFocus.tsx
create mode 100644 packages/trueforge-ui/src/hooks/useApprovalNav.ts
create mode 100644 packages/trueforge-ui/src/utils/findApprovalAncestors.ts
create mode 100644 packages/trueforge-ui/test/atoms/ApprovalNavBanner.test.tsx
create mode 100644 packages/trueforge-ui/test/utils/findApprovalAncestors.test.ts
diff --git a/.changeset/pre/approval-nav-banner.md b/.changeset/pre/approval-nav-banner.md
new file mode 100644
index 000000000..5f5e16a45
--- /dev/null
+++ b/.changeset/pre/approval-nav-banner.md
@@ -0,0 +1,5 @@
+---
+"@truefoundry/trueforge-ui": minor
+---
+
+Add a composer approval-nav banner (overridable `ApprovalNavBanner` slot) that counts pending tool approvals, pauses the composer, and jumps/expands/flashes the focused approval — including nested subagent tools.
diff --git a/packages/trueforge-ui/src/atoms/ApprovalNavBanner.tsx b/packages/trueforge-ui/src/atoms/ApprovalNavBanner.tsx
new file mode 100644
index 000000000..0359a56a3
--- /dev/null
+++ b/packages/trueforge-ui/src/atoms/ApprovalNavBanner.tsx
@@ -0,0 +1,87 @@
+'use client';
+
+import { Icon } from '../icons/Icon.js';
+import { cn } from './lib/cn.js';
+
+export type ApprovalNavBannerProps = {
+ count: number;
+ /** 1-based index for display, e.g. 1 in "(1/4)". */
+ current: number;
+ canPrev: boolean;
+ canNext: boolean;
+ onPrev: () => void;
+ onNext: () => void;
+ /** Scroll the thread to the currently selected approval. */
+ onFocusCurrent: () => void;
+ className?: string;
+};
+
+export function ApprovalNavBanner({
+ count,
+ current,
+ canPrev,
+ canNext,
+ onPrev,
+ onNext,
+ onFocusCurrent,
+ className,
+}: ApprovalNavBannerProps) {
+ // Figma Agents node 6747:4232 — "N tools need your input"
+ const label = count === 1 ? '1 tool needs your input' : `${String(count)} tools need your input`;
+
+ return (
+
+
+
+ {label}
+
+
+
+ ({current}/{count})
+
+
+
+
+
+ );
+}
+
+declare module '../theme/SlotsProvider.js' {
+ interface AtomSlots {
+ ApprovalNavBanner: typeof ApprovalNavBanner;
+ }
+}
diff --git a/packages/trueforge-ui/src/atoms/ToolCallCard.tsx b/packages/trueforge-ui/src/atoms/ToolCallCard.tsx
index 54b0e0304..08b3d821e 100644
--- a/packages/trueforge-ui/src/atoms/ToolCallCard.tsx
+++ b/packages/trueforge-ui/src/atoms/ToolCallCard.tsx
@@ -1,7 +1,8 @@
'use client';
-import type { ReactNode } from 'react';
+import { useRef, type ReactNode } from 'react';
+import { useOptionalApprovalFocus, useRegisterApprovalTarget } from '../containers/approvalFocus.js';
import { AgentStepRow, type AgentStepStatus } from './agent-chat/AgentStepRow.js';
import { cn } from './lib/cn.js';
@@ -22,6 +23,8 @@ export type ToolCallCardProps = {
showResponseLine?: boolean;
responseIcon?: ReactNode;
approvalSlot?: ReactNode;
+ /** When set, this card is the scroll/flash target for approval banner navigation. */
+ approvalId?: string;
requestSlot?: ReactNode;
responseSlot?: ReactNode;
highlightCard?: boolean;
@@ -46,6 +49,7 @@ export function ToolCallCard({
showExpandChevron = true,
showResponseLine = false,
approvalSlot,
+ approvalId,
requestSlot,
responseSlot,
highlightCard = false,
@@ -53,11 +57,16 @@ export function ToolCallCard({
mcpServerName: _mcpServerName,
dataTestPrefix,
}: ToolCallCardProps) {
+ const rootRef = useRef(null);
+ const focusApi = useOptionalApprovalFocus();
+ useRegisterApprovalTarget(approvalId, () => rootRef.current);
+
const hasApproval = !!approvalSlot;
const hasRequest = !!requestSlot;
const hasResponse = !!responseSlot;
const showConnector = hasApproval || hasRequest || hasResponse;
const isExpandable = showExpandChevron && (hasRequest || hasResponse);
+ const isFlashing = approvalId != null && focusApi?.flashingApprovalId === approvalId;
let derivedStatus: AgentStepStatus = 'idle';
if (explicitStatus) derivedStatus = explicitStatus;
@@ -73,9 +82,13 @@ export function ToolCallCard({
return (
+ );
+}
diff --git a/packages/trueforge-ui/src/containers/ComposerContainer.tsx b/packages/trueforge-ui/src/containers/ComposerContainer.tsx
index 009d1f1ae..4ee6fdf9a 100644
--- a/packages/trueforge-ui/src/containers/ComposerContainer.tsx
+++ b/packages/trueforge-ui/src/containers/ComposerContainer.tsx
@@ -9,6 +9,7 @@ import { useComposerBusyState } from '../hooks/useComposerBusyState.js';
import { useComposerPauseView } from '../hooks/useComposerPauseView.js';
import { useOptionalShellMode } from '../server/ShellModeContext.js';
import { SlotsProvider, useSlot, useSlotIsDefault } from '../theme/SlotsProvider.js';
+import { ApprovalNavContainer } from './ApprovalNavContainer.js';
import { AskUserContainer } from './AskUserContainer.js';
import { ComposerAttachmentsContainer } from './AttachmentsContainer.js';
import { CustomActionContainer } from './CustomActionContainer.js';
@@ -18,7 +19,16 @@ export type ComposerContainerProps = {
placeholder?: string;
};
-function ComposerBody({ placeholder }: { placeholder: string }) {
+function ComposerBody({
+ placeholder,
+ forceDisabled = false,
+ connectedToBanner = false,
+}: {
+ placeholder: string;
+ forceDisabled?: boolean;
+ /** Flatten top radius/border so the approval banner sits flush above. */
+ connectedToBanner?: boolean;
+}) {
const ComposerShell = useSlot('ComposerShell');
const aui = useAui();
const shell = useOptionalShellMode();
@@ -30,7 +40,8 @@ function ComposerBody({ placeholder }: { placeholder: string }) {
const { isBusy, send, resetBusy } = useComposerBusyState();
const cancel = useTrueFoundryCancel();
const fileInputRef = useRef(null);
- const canSubmit = !isBusy && hasText && (!requiresModel || hasModel);
+ const disabled = isBusy || forceDisabled;
+ const canSubmit = !disabled && hasText && (!requiresModel || hasModel);
const submit = () => {
if (!canSubmit) return;
send(() => aui.composer().send());
@@ -54,7 +65,7 @@ function ComposerBody({ placeholder }: { placeholder: string }) {
}}
/>
@@ -68,20 +79,26 @@ function ComposerBody({ placeholder }: { placeholder: string }) {
}}
>
}
input={
}
- disabled={isBusy}
+ disabled={disabled}
canSubmit={canSubmit}
- isRunning={isBusy}
+ isRunning={isBusy && !forceDisabled}
onSubmit={submit}
onCancel={() => {
resetBusy();
@@ -95,10 +112,15 @@ function ComposerBody({ placeholder }: { placeholder: string }) {
);
}
-export function ComposerContainer({
- placeholder = 'Ask anything... (Shift+Enter for new line)',
-}: ComposerContainerProps) {
- const pauseView = useComposerPauseView();
+function ComposerWithOptionalDraft({
+ placeholder,
+ forceDisabled = false,
+ connectedToBanner = false,
+}: {
+ placeholder: string;
+ forceDisabled?: boolean;
+ connectedToBanner?: boolean;
+}) {
const shell = useOptionalShellMode();
const parentLeftSection = useSlot('ComposerLeftSection');
const parentRightSection = useSlot('ComposerRightSection');
@@ -108,16 +130,6 @@ export function ComposerContainer({
const DraftComposerRightSection = useSlot('DraftComposerRightSection');
const canMutateSpec = shell?.mode.status === 'active' && shell.mode.isMutable;
- if (pauseView.kind === 'mcp') {
- return ;
- }
- if (pauseView.kind === 'custom') {
- return ;
- }
- if (pauseView.kind === 'ask-user') {
- return ;
- }
-
if (canMutateSpec) {
return (
@@ -127,11 +139,37 @@ export function ComposerContainer({
ComposerRightSection: usesDefaultRightSection ? DraftComposerRightSection : parentRightSection,
}}
>
-
+
);
}
- return ;
+ return ;
+}
+
+export function ComposerContainer({
+ placeholder = 'Ask anything... (Shift+Enter for new line)',
+}: ComposerContainerProps) {
+ const pauseView = useComposerPauseView();
+
+ if (pauseView.kind === 'mcp') {
+ return ;
+ }
+ if (pauseView.kind === 'custom') {
+ return ;
+ }
+ if (pauseView.kind === 'ask-user') {
+ return ;
+ }
+ if (pauseView.kind === 'approval') {
+ return (
+
+ );
+ }
+
+ return ;
}
diff --git a/packages/trueforge-ui/src/containers/ThreadContainer.tsx b/packages/trueforge-ui/src/containers/ThreadContainer.tsx
index ec04e72be..e8992664b 100644
--- a/packages/trueforge-ui/src/containers/ThreadContainer.tsx
+++ b/packages/trueforge-ui/src/containers/ThreadContainer.tsx
@@ -8,6 +8,7 @@ import { useSyncSessionTitle } from '../hooks/useSyncSessionTitle.js';
import { useOptionalShellMode } from '../server/ShellModeContext.js';
import { useSlot } from '../theme/SlotsProvider.js';
import { isNewChatView } from '../utils/isNewChatView.js';
+import { ApprovalFocusProvider } from './approvalFocus.js';
import { AssistantMessageContainer } from './AssistantMessageContainer.js';
import { HistoryLoaderContainer } from './HistoryLoaderContainer.js';
import { ResumeUnavailableContainer } from './ResumeUnavailableContainer.js';
@@ -58,44 +59,46 @@ export function ThreadContainer({ composer }: ThreadContainerProps) {
return (
-
-
-
-
- {isEmpty && }
- {isEmpty && !isLoading && composer}
- {isLoading ? (
-
- ) : (
- !isEmpty && (
- <>
-
-
-
- {({ message }) => (
-
-
-
- )}
-
-
-
- >
- )
- )}
-
-
+
+
+
+
+
+ {isEmpty && }
+ {isEmpty && !isLoading && composer}
+ {isLoading ? (
+
+ ) : (
+ !isEmpty && (
+ <>
+
+
+
+ {({ message }) => (
+
+
+
+ )}
+
+
+
+ >
+ )
+ )}
+
+
- {!isLoading && !isEmpty && (
-
-
-
-
- {composer}
-
- )}
-
-
+ {!isLoading && !isEmpty && (
+
+
+
+
+ {composer}
+
+ )}
+
+
+
);
}
diff --git a/packages/trueforge-ui/src/containers/ToolApprovalContainer.tsx b/packages/trueforge-ui/src/containers/ToolApprovalContainer.tsx
index 51f1e554a..99e036ebe 100644
--- a/packages/trueforge-ui/src/containers/ToolApprovalContainer.tsx
+++ b/packages/trueforge-ui/src/containers/ToolApprovalContainer.tsx
@@ -21,6 +21,8 @@ export type ToolApprovalOption = {
type ToolApprovalContainerProps = {
toolName?: string;
argsText?: string;
+ /** Kept for call-site compat; flash/scroll target is `ToolCallCard`. */
+ approvalId?: string;
options: ToolApprovalOption[];
onSelectOption: (optionId: string, reason?: string) => void;
};
@@ -28,6 +30,7 @@ type ToolApprovalContainerProps = {
export function ToolApprovalContainer({
toolName = '',
argsText,
+ approvalId: _approvalId,
options,
onSelectOption,
}: ToolApprovalContainerProps) {
diff --git a/packages/trueforge-ui/src/containers/ToolCallContainer.tsx b/packages/trueforge-ui/src/containers/ToolCallContainer.tsx
index 6ee8660d7..fba442959 100644
--- a/packages/trueforge-ui/src/containers/ToolCallContainer.tsx
+++ b/packages/trueforge-ui/src/containers/ToolCallContainer.tsx
@@ -9,7 +9,7 @@ import {
type ToolCallMessagePartProps,
} from '@assistant-ui/react';
import { useTrueFoundryRespondToToolApproval } from '@truefoundry/assistant-ui-runtime';
-import { useState } from 'react';
+import { useCallback, useState } from 'react';
import { useSlot } from '../theme/SlotsProvider.js';
import {
@@ -32,6 +32,7 @@ import {
resolveSubAgentMeta,
toStatus,
} from '../utils/toolCallParsing.js';
+import { useRegisterApprovalExpand } from './approvalFocus.js';
import { AssistantTextContainer } from './AssistantTextContainer.js';
import { NestedApprovalBridgeContext, useNestedApprovalBridge } from './nestedApprovalBridge.js';
import { SandboxToolCallContainer } from './SandboxToolCallContainer.js';
@@ -101,6 +102,7 @@ function ToolApprovalSlot({ part }: { part: ToolCallMessagePartProps }) {
{
const durationText = elapsedMs === undefined ? undefined : formatDuration(elapsedMs);
const status = toStatus(part.status?.type);
const onToggle = () => setExpanded(prev => !prev);
+ const expandSubAgent = useCallback(() => setExpanded(true), []);
+ useRegisterApprovalExpand(isSubAgent ? part.toolCallId : '', expandSubAgent);
if (part.toolName === ASK_USER_TOOL_NAME) {
if (hasPendingAskUserResponse(part)) {
@@ -279,6 +283,7 @@ export const ToolCallContainer: ToolCallMessagePartComponent = part => {
mcpServerName={mcpServer}
{...slots}
approvalSlot={showApproval ? : undefined}
+ approvalId={showApproval ? part.approval?.id : undefined}
/>
);
}
@@ -300,6 +305,7 @@ export const ToolCallContainer: ToolCallMessagePartComponent = part => {
showResponseLine={status !== 'running' && resultDisplay.data !== undefined}
{...slots}
approvalSlot={showApproval ? : undefined}
+ approvalId={showApproval ? part.approval?.id : undefined}
/>
);
};
diff --git a/packages/trueforge-ui/src/containers/approvalFocus.tsx b/packages/trueforge-ui/src/containers/approvalFocus.tsx
new file mode 100644
index 000000000..6605e4e2e
--- /dev/null
+++ b/packages/trueforge-ui/src/containers/approvalFocus.tsx
@@ -0,0 +1,147 @@
+'use client';
+
+import { useAuiState } from '@assistant-ui/react';
+import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react';
+
+import { findSubAgentAncestorsForApproval } from '../utils/findApprovalAncestors.js';
+
+const FLASH_MS = 2000;
+const MOUNT_RETRY_MS = 50;
+const MOUNT_RETRY_MAX = 12;
+
+type ApprovalFocusApi = {
+ registerTarget: (approvalId: string, getElement: () => HTMLElement | null) => () => void;
+ registerExpand: (toolCallId: string, expand: () => void) => () => void;
+ focus: (approvalId: string) => void;
+ flashingApprovalId: string | null;
+};
+
+const ApprovalFocusContext = createContext(null);
+
+export function ApprovalFocusProvider({ children }: { children: ReactNode }) {
+ const targetsRef = useRef(new Map HTMLElement | null>());
+ const expandsRef = useRef(new Map void>());
+ const flashTimerRef = useRef | null>(null);
+ const retryTimerRef = useRef | null>(null);
+ const [flashingApprovalId, setFlashingApprovalId] = useState(null);
+ const messages = useAuiState(s => s.thread.messages);
+
+ const registerTarget = useCallback((approvalId: string, getElement: () => HTMLElement | null) => {
+ targetsRef.current.set(approvalId, getElement);
+ return () => {
+ if (targetsRef.current.get(approvalId) === getElement) {
+ targetsRef.current.delete(approvalId);
+ }
+ };
+ }, []);
+
+ const registerExpand = useCallback((toolCallId: string, expand: () => void) => {
+ expandsRef.current.set(toolCallId, expand);
+ return () => {
+ if (expandsRef.current.get(toolCallId) === expand) {
+ expandsRef.current.delete(toolCallId);
+ }
+ };
+ }, []);
+
+ const startFlash = useCallback((approvalId: string) => {
+ if (flashTimerRef.current != null) clearTimeout(flashTimerRef.current);
+ setFlashingApprovalId(approvalId);
+ flashTimerRef.current = setTimeout(() => {
+ setFlashingApprovalId(null);
+ flashTimerRef.current = null;
+ }, FLASH_MS);
+ }, []);
+
+ useEffect(
+ () => () => {
+ if (flashTimerRef.current != null) clearTimeout(flashTimerRef.current);
+ if (retryTimerRef.current != null) clearInterval(retryTimerRef.current);
+ },
+ [],
+ );
+
+ const focus = useCallback(
+ (approvalId: string) => {
+ const ancestors = findSubAgentAncestorsForApproval(messages, approvalId);
+ for (const toolCallId of ancestors) {
+ expandsRef.current.get(toolCallId)?.();
+ }
+
+ if (retryTimerRef.current != null) {
+ clearInterval(retryTimerRef.current);
+ retryTimerRef.current = null;
+ }
+
+ const tryScroll = (): boolean => {
+ const el = targetsRef.current.get(approvalId)?.() ?? null;
+ if (el == null) return false;
+ el.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
+ startFlash(approvalId);
+ return true;
+ };
+
+ if (tryScroll()) return;
+
+ let attempts = 0;
+ retryTimerRef.current = setInterval(() => {
+ attempts += 1;
+ if (tryScroll() || attempts >= MOUNT_RETRY_MAX) {
+ if (retryTimerRef.current != null) {
+ clearInterval(retryTimerRef.current);
+ retryTimerRef.current = null;
+ }
+ }
+ }, MOUNT_RETRY_MS);
+ },
+ [messages, startFlash],
+ );
+
+ const api = useMemo(
+ () => ({
+ registerTarget,
+ registerExpand,
+ focus,
+ flashingApprovalId,
+ }),
+ [registerTarget, registerExpand, focus, flashingApprovalId],
+ );
+
+ return {children};
+}
+
+export function useApprovalFocus(): ApprovalFocusApi {
+ const api = useContext(ApprovalFocusContext);
+ if (api == null) {
+ throw new Error('useApprovalFocus requires ApprovalFocusProvider');
+ }
+ return api;
+}
+
+export function useOptionalApprovalFocus(): ApprovalFocusApi | null {
+ return useContext(ApprovalFocusContext);
+}
+
+/** Registers a DOM target for scroll/flash. No-ops outside the provider (tests). */
+export function useRegisterApprovalTarget(approvalId: string | undefined, getElement: () => HTMLElement | null): void {
+ const api = useOptionalApprovalFocus();
+ const getElementRef = useRef(getElement);
+ getElementRef.current = getElement;
+
+ useEffect(() => {
+ if (api == null || approvalId == null || approvalId === '') return;
+ return api.registerTarget(approvalId, () => getElementRef.current());
+ }, [api, approvalId]);
+}
+
+/** Registers a sub-agent expand callback. No-ops outside the provider (tests). */
+export function useRegisterApprovalExpand(toolCallId: string, expand: () => void): void {
+ const api = useOptionalApprovalFocus();
+ const expandRef = useRef(expand);
+ expandRef.current = expand;
+
+ useEffect(() => {
+ if (api == null || toolCallId === '') return;
+ return api.registerExpand(toolCallId, () => expandRef.current());
+ }, [api, toolCallId]);
+}
diff --git a/packages/trueforge-ui/src/hooks/useApprovalNav.ts b/packages/trueforge-ui/src/hooks/useApprovalNav.ts
new file mode 100644
index 000000000..b02df5bf6
--- /dev/null
+++ b/packages/trueforge-ui/src/hooks/useApprovalNav.ts
@@ -0,0 +1,80 @@
+'use client';
+
+import { useTrueFoundryApprovals } from '@truefoundry/assistant-ui-runtime';
+import { useCallback, useEffect, useRef, useState } from 'react';
+
+import { useOptionalApprovalFocus } from '../containers/approvalFocus.js';
+
+export type ApprovalNavState = {
+ count: number;
+ /** 0-based index into pending approvals. */
+ index: number;
+ canPrev: boolean;
+ canNext: boolean;
+ goPrev: () => void;
+ goNext: () => void;
+ /** Scroll to the currently selected approval (e.g. after the user scrolled away). */
+ focusCurrent: () => void;
+};
+
+/**
+ * Tracks the focused pending tool approval for the composer banner.
+ * Auto-focuses #1 when approvals appear; after allow/deny, stays on the same
+ * index so the next pending item fills the slot; chevrons do not wrap.
+ */
+export function useApprovalNav(): ApprovalNavState {
+ const { pending } = useTrueFoundryApprovals();
+ const focusApi = useOptionalApprovalFocus();
+ const [index, setIndex] = useState(0);
+ const prevCountRef = useRef(0);
+ const focusedIdRef = useRef(null);
+
+ const count = pending.length;
+ const safeIndex = count === 0 ? 0 : Math.min(index, count - 1);
+ const currentId = pending[safeIndex]?.approvalId;
+
+ useEffect(() => {
+ if (safeIndex !== index) setIndex(safeIndex);
+ }, [safeIndex, index]);
+
+ useEffect(() => {
+ if (prevCountRef.current === 0 && count > 0) {
+ setIndex(0);
+ focusedIdRef.current = null;
+ }
+ prevCountRef.current = count;
+ }, [count]);
+
+ useEffect(() => {
+ if (currentId == null || focusApi == null) {
+ if (currentId == null) focusedIdRef.current = null;
+ return;
+ }
+ if (focusedIdRef.current === currentId) return;
+ focusedIdRef.current = currentId;
+ focusApi.focus(currentId);
+ }, [currentId, focusApi]);
+
+ const goPrev = useCallback(() => {
+ setIndex(i => Math.max(0, i - 1));
+ }, []);
+
+ const goNext = useCallback(() => {
+ setIndex(i => Math.min(Math.max(count - 1, 0), i + 1));
+ }, [count]);
+
+ const focusCurrent = useCallback(() => {
+ if (currentId == null || focusApi == null) return;
+ focusApi.focus(currentId);
+ }, [currentId, focusApi]);
+
+ return {
+ count,
+ index: safeIndex,
+ canPrev: safeIndex > 0,
+ canNext: safeIndex < count - 1,
+ goPrev,
+ goNext,
+ focusCurrent,
+ };
+}
diff --git a/packages/trueforge-ui/src/hooks/useComposerPauseView.ts b/packages/trueforge-ui/src/hooks/useComposerPauseView.ts
index 884533e54..89bb35dd1 100644
--- a/packages/trueforge-ui/src/hooks/useComposerPauseView.ts
+++ b/packages/trueforge-ui/src/hooks/useComposerPauseView.ts
@@ -1,7 +1,7 @@
'use client';
import { useAuiState } from '@assistant-ui/react';
-import { useTrueFoundryToolResponses } from '@truefoundry/assistant-ui-runtime';
+import { useTrueFoundryApprovals, useTrueFoundryToolResponses } from '@truefoundry/assistant-ui-runtime';
import { useOptionalCustomActionRenderers } from '../server/CustomActionRenderersContext.js';
@@ -25,12 +25,17 @@ export function threadHasPendingMcpAuth(s: ThreadPauseState): boolean {
}
export type ComposerPauseView =
- { kind: 'mcp' } | { kind: 'custom'; toolName: string } | { kind: 'ask-user' } | { kind: 'compose' };
+ | { kind: 'mcp' }
+ | { kind: 'custom'; toolName: string }
+ | { kind: 'ask-user' }
+ | { kind: 'approval' }
+ | { kind: 'compose' };
/** Shared composer pause detection for default and custom composer containers. */
export function useComposerPauseView(): ComposerPauseView {
const mcpPending = useAuiState(threadHasPendingMcpAuth);
const { pending: toolResponsesPending } = useTrueFoundryToolResponses();
+ const { pending: approvalsPending } = useTrueFoundryApprovals();
const customActionRenderers = useOptionalCustomActionRenderers();
if (mcpPending) {
@@ -46,5 +51,9 @@ export function useComposerPauseView(): ComposerPauseView {
return { kind: 'ask-user' };
}
+ if (approvalsPending.length > 0) {
+ return { kind: 'approval' };
+ }
+
return { kind: 'compose' };
}
diff --git a/packages/trueforge-ui/src/index.ts b/packages/trueforge-ui/src/index.ts
index ca2f6ccbb..6ac72197f 100644
--- a/packages/trueforge-ui/src/index.ts
+++ b/packages/trueforge-ui/src/index.ts
@@ -151,6 +151,7 @@ export { ToolGroupCard } from './atoms/ToolGroupCard.js';
export type { ToolGroupCardProps } from './atoms/ToolGroupCard.js';
export { AgentStepsContainer } from './containers/AgentStepsContainer.js';
export type { AgentStepsContainerProps } from './containers/AgentStepsContainer.js';
+export { ApprovalNavContainer } from './containers/ApprovalNavContainer.js';
export { AskUserContainer } from './containers/AskUserContainer.js';
export { AssistantMessageContainer } from './containers/AssistantMessageContainer.js';
export { AssistantTextContainer } from './containers/AssistantTextContainer.js';
@@ -192,6 +193,8 @@ export { TrueFoundryChatProvider } from './containers/TrueFoundryChatProvider.js
export type { TrueFoundryChatProviderProps } from './containers/TrueFoundryChatProvider.js';
export { UserEditComposerContainer } from './containers/UserEditComposerContainer.js';
export { UserMessageContainer } from './containers/UserMessageContainer.js';
+export { useApprovalNav } from './hooks/useApprovalNav.js';
+export type { ApprovalNavState } from './hooks/useApprovalNav.js';
export { ComposerBusyProvider, useComposerBusyState } from './hooks/useComposerBusyState.js';
export type { ComposerBusyState } from './hooks/useComposerBusyState.js';
export { threadHasPendingMcpAuth, useComposerPauseView } from './hooks/useComposerPauseView.js';
@@ -210,6 +213,7 @@ export {
trueFoundryAttachmentAdapter,
useTrueFoundryAgentRuntime,
useTrueFoundryAgentSpec,
+ useTrueFoundryApprovals,
useTrueFoundryCancel,
useTrueFoundryDownloadSandboxFile,
useTrueFoundryHistoryPagination,
@@ -344,12 +348,15 @@ export type {
// Utils
export { computeAgentStepsSplit } from './utils/computeAgentStepsSplit.js';
export type { AgentStepPart, AgentStepsSplitResult } from './utils/computeAgentStepsSplit.js';
+export { findSubAgentAncestorsForApproval } from './utils/findApprovalAncestors.js';
export { getErrorMessage } from './utils/getErrorMessage.js';
export { AgentsLibrary } from './atoms/AgentsLibrary.js';
export type { AgentsLibraryProps } from './atoms/AgentsLibrary.js';
export { AgentsLibraryButton } from './atoms/AgentsLibraryButton.js';
export type { AgentsLibraryButtonProps } from './atoms/AgentsLibraryButton.js';
+export { ApprovalNavBanner } from './atoms/ApprovalNavBanner.js';
+export type { ApprovalNavBannerProps } from './atoms/ApprovalNavBanner.js';
export { DraftCatalogProvider, useDraftCatalog } from './atoms/draft/DraftCatalogProvider.js';
export { DraftComposerLeftSection, DraftComposerRightSection } from './atoms/draft/DraftComposerSections.js';
export { DraftCompositeSelector } from './atoms/draft/DraftCompositeSelector.js';
diff --git a/packages/trueforge-ui/src/styles.css b/packages/trueforge-ui/src/styles.css
index 24fb446fe..df304cdb5 100644
--- a/packages/trueforge-ui/src/styles.css
+++ b/packages/trueforge-ui/src/styles.css
@@ -79,6 +79,11 @@
--failure-text: var(--color-white);
--warning-bg: #d97706;
--warning-text: var(--color-white);
+ /* Figma Agents approval banner (light 6747:4232): blue-50 / blue-500 / blue-600 */
+ --approval-banner-bg: #eff6ff;
+ --approval-banner-border: #3b82f6;
+ --approval-banner-fg: #2563eb;
+ --approval-banner-accent: #3b82f6;
--focus-ring: var(--color-black);
--radius: 0.5rem;
--composer-radius: 1.5rem;
@@ -142,6 +147,11 @@
--failure-text: var(--color-white);
--warning-bg: #fbbf24;
--warning-text: #422006;
+ /* Figma Agents approval banner (dark 6748:1977): #202636 / blue-600 top / Base_gray/750 text */
+ --approval-banner-bg: #202636;
+ --approval-banner-border: #2563eb;
+ --approval-banner-fg: #dfdfe2;
+ --approval-banner-accent: #2563eb;
--focus-ring: var(--color-indigo-500);
--overlay: rgb(0 0 0 / 0.7);
--shadow-color: #000000;
@@ -285,6 +295,10 @@
--color-failure-text: var(--failure-text);
--color-warning-bg: var(--warning-bg);
--color-warning-text: var(--warning-text);
+ --color-approval-banner-bg: var(--approval-banner-bg);
+ --color-approval-banner-border: var(--approval-banner-border);
+ --color-approval-banner-fg: var(--approval-banner-fg);
+ --color-approval-banner-accent: var(--approval-banner-accent);
--color-focus-ring: var(--focus-ring);
--color-white: var(--color-white);
--color-black: var(--color-black);
@@ -345,3 +359,30 @@
transform: translateY(100%);
}
}
+
+/* Tool-approval focus flash (composer banner nav). */
+@keyframes aui-approval-flash {
+ 0%,
+ 100% {
+ background-color: transparent;
+ }
+ 15%,
+ 40%,
+ 65% {
+ background-color: color-mix(in srgb, var(--approval-banner-accent) 18%, transparent);
+ }
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .aui-approval-flash {
+ animation: aui-approval-flash 2s ease-in-out;
+ border-radius: 0.375rem;
+ }
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .aui-approval-flash {
+ background-color: color-mix(in srgb, var(--approval-banner-accent) 18%, transparent);
+ border-radius: 0.375rem;
+ }
+}
diff --git a/packages/trueforge-ui/src/theme/defaultSlots.ts b/packages/trueforge-ui/src/theme/defaultSlots.ts
index e3a9f07c1..9cbf5d23e 100644
--- a/packages/trueforge-ui/src/theme/defaultSlots.ts
+++ b/packages/trueforge-ui/src/theme/defaultSlots.ts
@@ -8,6 +8,7 @@ import { ResumeUnavailable } from '../atoms/ResumeUnavailable.js';
import { AgentsLibrary } from '../atoms/AgentsLibrary.js';
import { AgentsLibraryButton } from '../atoms/AgentsLibraryButton.js';
+import { ApprovalNavBanner } from '../atoms/ApprovalNavBanner.js';
import { AssistantMessageBubble } from '../atoms/AssistantMessageBubble.js';
import { AttachmentCard } from '../atoms/AttachmentCard.js';
import { AttachmentPickerButton } from '../atoms/AttachmentPickerButton.js';
@@ -114,6 +115,7 @@ export const defaultSlots = {
ThreadListNewButton,
AgentsLibrary,
AgentsLibraryButton,
+ ApprovalNavBanner,
SaveAgentButton,
SelectAgentEmptyState,
ClearChatButton,
diff --git a/packages/trueforge-ui/src/utils/findApprovalAncestors.ts b/packages/trueforge-ui/src/utils/findApprovalAncestors.ts
new file mode 100644
index 000000000..721ff12b1
--- /dev/null
+++ b/packages/trueforge-ui/src/utils/findApprovalAncestors.ts
@@ -0,0 +1,64 @@
+import { SUB_AGENT_TOOL_NAME } from './toolCallParsing.js';
+
+type ApprovalRef = {
+ id?: string;
+ approved?: boolean;
+ resolution?: string;
+};
+
+type ToolCallPart = {
+ type: string;
+ toolCallId?: string;
+ toolName?: string;
+ approval?: ApprovalRef;
+ messages?: readonly {
+ role: string;
+ content: readonly ToolCallPart[];
+ }[];
+};
+
+type ThreadMessageLike = {
+ role: string;
+ content: readonly ToolCallPart[];
+};
+
+function isPendingApproval(approval: ApprovalRef | undefined, approvalId: string): boolean {
+ return approval?.id === approvalId && approval.approved === undefined && approval.resolution === undefined;
+}
+
+/**
+ * Returns `create_sub_agent` toolCallIds that must be expanded for `approvalId`
+ * to mount in the DOM. Empty when the approval is on the root thread.
+ */
+export function findSubAgentAncestorsForApproval(messages: readonly ThreadMessageLike[], approvalId: string): string[] {
+ const path: string[] = [];
+
+ function walk(content: readonly ToolCallPart[], ancestors: readonly string[]): boolean {
+ for (const part of content) {
+ if (part.type !== 'tool-call') continue;
+
+ if (isPendingApproval(part.approval, approvalId)) {
+ path.push(...ancestors);
+ return true;
+ }
+
+ const nextAncestors =
+ part.toolName === SUB_AGENT_TOOL_NAME && part.toolCallId != null && part.toolCallId !== ''
+ ? [...ancestors, part.toolCallId]
+ : ancestors;
+
+ if (part.messages == null) continue;
+ for (const message of part.messages) {
+ if (message.role !== 'assistant') continue;
+ if (walk(message.content, nextAncestors)) return true;
+ }
+ }
+ return false;
+ }
+
+ for (const message of messages) {
+ if (message.role !== 'assistant') continue;
+ if (walk(message.content, [])) return path;
+ }
+ return path;
+}
diff --git a/packages/trueforge-ui/test/atoms/ApprovalNavBanner.test.tsx b/packages/trueforge-ui/test/atoms/ApprovalNavBanner.test.tsx
new file mode 100644
index 000000000..6fbfcdbec
--- /dev/null
+++ b/packages/trueforge-ui/test/atoms/ApprovalNavBanner.test.tsx
@@ -0,0 +1,100 @@
+// @vitest-environment jsdom
+import { fireEvent, render, screen } from '@testing-library/react';
+import { describe, expect, it, vi } from 'vitest';
+
+import { ApprovalNavBanner } from '@/atoms/ApprovalNavBanner.js';
+
+describe('ApprovalNavBanner', () => {
+ it('renders the count label and disables chevrons at the ends', () => {
+ const onPrev = vi.fn();
+ const onNext = vi.fn();
+ const onFocusCurrent = vi.fn();
+
+ const { rerender } = render(
+ ,
+ );
+
+ expect(screen.getByText('4 tools need your input')).toBeTruthy();
+ expect(screen.getByText('(1/4)')).toBeTruthy();
+ expect(screen.getByRole('button', { name: 'Previous approval' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Next approval' })).not.toBeDisabled();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Next approval' }));
+ expect(onNext).toHaveBeenCalledOnce();
+
+ rerender(
+ ,
+ );
+ expect(screen.getByRole('button', { name: 'Next approval' })).toBeDisabled();
+ expect(screen.getByRole('button', { name: 'Previous approval' })).not.toBeDisabled();
+ });
+
+ it('uses singular copy for one pending approval', () => {
+ render(
+ {}}
+ onNext={() => {}}
+ onFocusCurrent={() => {}}
+ />,
+ );
+ expect(screen.getByText('1 tool needs your input')).toBeTruthy();
+ });
+
+ it('focuses the current approval when the banner is clicked', () => {
+ const onFocusCurrent = vi.fn();
+ render(
+ {}}
+ onNext={() => {}}
+ onFocusCurrent={onFocusCurrent}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('status'));
+ expect(onFocusCurrent).toHaveBeenCalledOnce();
+ });
+
+ it('does not focus when chevrons are clicked', () => {
+ const onFocusCurrent = vi.fn();
+ const onNext = vi.fn();
+ render(
+ {}}
+ onNext={onNext}
+ onFocusCurrent={onFocusCurrent}
+ />,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Next approval' }));
+ expect(onNext).toHaveBeenCalledOnce();
+ expect(onFocusCurrent).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx b/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx
index 791cd7969..dfc603c2f 100644
--- a/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx
+++ b/packages/trueforge-ui/test/containers/ComposerContainer.test.tsx
@@ -22,9 +22,20 @@ const toolResponsesState = vi.hoisted(() => ({
respond: vi.fn(),
}));
+const approvalsState = vi.hoisted(() => ({
+ pending: [] as Array<{
+ approvalId: string;
+ threadId: string;
+ toolName: string;
+ args: Record;
+ argsText: string;
+ }>,
+}));
+
vi.mock('@truefoundry/assistant-ui-runtime', () => ({
useTrueFoundryCancel: () => vi.fn(),
useTrueFoundryToolResponses: () => toolResponsesState,
+ useTrueFoundryApprovals: () => approvalsState,
useTrueFoundryAgentSpec: () => ({ agentSpec: agentSpecState.agentSpec }),
}));
@@ -59,6 +70,7 @@ describe('ComposerContainer', () => {
agentSpecState.agentSpec = { model: { name: 'test/model' } };
toolResponsesState.pending = [];
toolResponsesState.respond = vi.fn();
+ approvalsState.pending = [];
});
it('wraps the composer in an attachment dropzone by default', () => {
renderComposer();
@@ -191,4 +203,30 @@ describe('ComposerContainer', () => {
content: 'chosen-secret',
});
});
+
+ it('shows the approval banner above a disabled composer while approvals are pending', () => {
+ approvalsState.pending = [
+ {
+ approvalId: 'appr-1',
+ threadId: 'root',
+ toolName: 'call_tool',
+ args: {},
+ argsText: '{}',
+ },
+ {
+ approvalId: 'appr-2',
+ threadId: 'root',
+ toolName: 'call_tool',
+ args: {},
+ argsText: '{}',
+ },
+ ];
+
+ renderComposer();
+
+ expect(screen.getByText('2 tools need your input')).toBeInTheDocument();
+ expect(screen.getByText('(1/2)')).toBeInTheDocument();
+ expect(screen.getByRole('textbox', { name: 'Message input' })).toBeDisabled();
+ expect(document.querySelector('[data-slot="aui_composer-approval-pause"]')).toBeInTheDocument();
+ });
});
diff --git a/packages/trueforge-ui/test/containers/Thread.test.tsx b/packages/trueforge-ui/test/containers/Thread.test.tsx
index ebdd01907..cd93d466c 100644
--- a/packages/trueforge-ui/test/containers/Thread.test.tsx
+++ b/packages/trueforge-ui/test/containers/Thread.test.tsx
@@ -10,6 +10,7 @@ import { RuntimeHarness } from './RuntimeHarness.js';
vi.mock('@truefoundry/assistant-ui-runtime', () => ({
useTrueFoundryCancel: () => vi.fn(),
useTrueFoundryToolResponses: () => ({ pending: [] }),
+ useTrueFoundryApprovals: () => ({ pending: [] }),
useTrueFoundryAgentSpec: () => ({ agentSpec: { model: { name: 'test/model' } } }),
}));
diff --git a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx
index a020840e6..5e6fc7689 100644
--- a/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx
+++ b/packages/trueforge-ui/test/containers/TrueForgeUI.test.tsx
@@ -16,6 +16,7 @@ vi.mock('@truefoundry/assistant-ui-runtime', () => ({
}),
useTrueFoundryCancel: () => vi.fn(),
useTrueFoundryToolResponses: () => ({ pending: [] }),
+ useTrueFoundryApprovals: () => ({ pending: [] }),
useTrueFoundryRespondToToolApproval: () => vi.fn(),
useTrueFoundryMcpAuth: () => ({ pending: [], connect: vi.fn(), continue: vi.fn() }),
useTrueFoundryHistoryPagination: () => ({
diff --git a/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx b/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx
index cc10eb600..2d520dbf4 100644
--- a/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx
+++ b/packages/trueforge-ui/test/hooks/useComposerPauseView.test.tsx
@@ -5,6 +5,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const useAuiState = vi.hoisted(() => vi.fn());
const useToolResponses = vi.hoisted(() => vi.fn());
+const useApprovals = vi.hoisted(() => vi.fn());
vi.mock('@assistant-ui/react', () => ({
useAuiState,
@@ -12,6 +13,7 @@ vi.mock('@assistant-ui/react', () => ({
vi.mock('@truefoundry/assistant-ui-runtime', () => ({
useTrueFoundryToolResponses: useToolResponses,
+ useTrueFoundryApprovals: useApprovals,
}));
import { threadHasPendingMcpAuth, type ThreadPauseState, useComposerPauseView } from '@/hooks/useComposerPauseView.js';
@@ -84,6 +86,7 @@ describe('useComposerPauseView', () => {
vi.clearAllMocks();
useAuiState.mockReturnValue(false);
useToolResponses.mockReturnValue({ pending: [] });
+ useApprovals.mockReturnValue({ pending: [] });
});
it('returns the normal composer when no pause source is pending', () => {
@@ -149,4 +152,25 @@ describe('useComposerPauseView', () => {
expect(result.current).toEqual({ kind: 'custom', toolName: 'ask_user_question' });
});
+
+ it('shows the approval pause when tool approvals are pending', () => {
+ useApprovals.mockReturnValue({
+ pending: [{ approvalId: 'appr-1', threadId: 'root', toolName: 'call_tool', args: {}, argsText: '{}' }],
+ });
+
+ const { result } = renderHook(() => useComposerPauseView());
+
+ expect(result.current).toEqual({ kind: 'approval' });
+ });
+
+ it('gives ask-user precedence over pending tool approvals', () => {
+ useToolResponses.mockReturnValue({ pending: [{ toolCallId: 'question-1', toolName: 'ask_user_question' }] });
+ useApprovals.mockReturnValue({
+ pending: [{ approvalId: 'appr-1', threadId: 'root', toolName: 'call_tool', args: {}, argsText: '{}' }],
+ });
+
+ const { result } = renderHook(() => useComposerPauseView());
+
+ expect(result.current).toEqual({ kind: 'ask-user' });
+ });
});
diff --git a/packages/trueforge-ui/test/publicUiExports.test.ts b/packages/trueforge-ui/test/publicUiExports.test.ts
index aeb910515..cb7a23cd4 100644
--- a/packages/trueforge-ui/test/publicUiExports.test.ts
+++ b/packages/trueforge-ui/test/publicUiExports.test.ts
@@ -7,6 +7,8 @@ const expectedRuntimeExports: Array = [
'AgentStepsContainer',
'AgentsLibrary',
'AgentsLibraryButton',
+ 'ApprovalNavBanner',
+ 'ApprovalNavContainer',
'AskUserContainer',
'AssistantMessageBubble',
'AssistantMessageContainer',
@@ -106,6 +108,7 @@ const expectedRuntimeExports: Array = [
'computeAgentStepsSplit',
'createTrueFoundryServer',
'defaultSlots',
+ 'findSubAgentAncestorsForApproval',
'getErrorMessage',
'libraryAgentId',
'mergeAgentSpec',
@@ -115,6 +118,7 @@ const expectedRuntimeExports: Array = [
'shellIsMutable',
'threadHasPendingMcpAuth',
'trueFoundryAttachmentAdapter',
+ 'useApprovalNav',
'useAui',
'useAuiState',
'useBrand',
@@ -141,6 +145,7 @@ const expectedRuntimeExports: Array = [
'useThemeMode',
'useTrueFoundryAgentRuntime',
'useTrueFoundryAgentSpec',
+ 'useTrueFoundryApprovals',
'useTrueFoundryCancel',
'useTrueFoundryDownloadSandboxFile',
'useTrueFoundryHistoryPagination',
diff --git a/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx b/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx
index 8441653fb..f42b5eebe 100644
--- a/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx
+++ b/packages/trueforge-ui/test/routing/withRouter.integration.test.tsx
@@ -38,6 +38,7 @@ vi.mock('@truefoundry/assistant-ui-runtime', () => ({
},
useTrueFoundryCancel: () => vi.fn(),
useTrueFoundryToolResponses: () => ({ pending: [] }),
+ useTrueFoundryApprovals: () => ({ pending: [] }),
useTrueFoundryRespondToToolApproval: () => vi.fn(),
useTrueFoundryMcpAuth: () => ({ pending: [], connect: vi.fn(), continue: vi.fn() }),
useTrueFoundryHistoryPagination: () => ({
diff --git a/packages/trueforge-ui/test/utils/findApprovalAncestors.test.ts b/packages/trueforge-ui/test/utils/findApprovalAncestors.test.ts
new file mode 100644
index 000000000..cde328fbf
--- /dev/null
+++ b/packages/trueforge-ui/test/utils/findApprovalAncestors.test.ts
@@ -0,0 +1,83 @@
+import { describe, expect, it } from 'vitest';
+
+import { findSubAgentAncestorsForApproval } from '@/utils/findApprovalAncestors.js';
+
+describe('findSubAgentAncestorsForApproval', () => {
+ it('returns empty when the approval is on the root thread', () => {
+ const messages = [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool-call',
+ toolCallId: 'tc-root',
+ toolName: 'call_tool',
+ approval: { id: 'appr-1' },
+ },
+ ],
+ },
+ ];
+
+ expect(findSubAgentAncestorsForApproval(messages, 'appr-1')).toEqual([]);
+ });
+
+ it('returns the create_sub_agent toolCallId for a nested pending approval', () => {
+ const messages = [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool-call',
+ toolCallId: 'sub-1',
+ toolName: 'create_sub_agent',
+ messages: [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool-call',
+ toolCallId: 'tc-nested',
+ toolName: 'call_tool',
+ approval: { id: 'appr-nested' },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ expect(findSubAgentAncestorsForApproval(messages, 'appr-nested')).toEqual(['sub-1']);
+ });
+
+ it('ignores resolved approvals', () => {
+ const messages = [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool-call',
+ toolCallId: 'sub-1',
+ toolName: 'create_sub_agent',
+ messages: [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool-call',
+ toolCallId: 'tc-nested',
+ toolName: 'call_tool',
+ approval: { id: 'appr-nested', approved: true },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ expect(findSubAgentAncestorsForApproval(messages, 'appr-nested')).toEqual([]);
+ });
+});
From 2c31a1be4391bccbccee3ef5821185b90901bff9 Mon Sep 17 00:00:00 2001
From: Govinda Vashishtha <57435703+govindavashishtha@users.noreply.github.com>
Date: Thu, 27 Aug 2026 19:25:18 +0530
Subject: [PATCH 2/3] fixed PR comments
---
.../src/atoms/SandboxToolCallCard.tsx | 9 ++
.../src/containers/ToolCallContainer.tsx | 4 +
.../src/containers/approvalFocus.tsx | 13 ++-
.../trueforge-ui/src/hooks/useApprovalNav.ts | 34 +++---
.../containers/ToolCallContainer.test.tsx | 34 ++++++
.../test/containers/approvalFocus.test.tsx | 110 ++++++++++++++++++
.../test/hooks/useApprovalNav.test.tsx | 71 +++++++++++
.../test/utils/findApprovalAncestors.test.ts | 42 +++++++
8 files changed, 294 insertions(+), 23 deletions(-)
create mode 100644 packages/trueforge-ui/test/containers/approvalFocus.test.tsx
create mode 100644 packages/trueforge-ui/test/hooks/useApprovalNav.test.tsx
diff --git a/packages/trueforge-ui/src/atoms/SandboxToolCallCard.tsx b/packages/trueforge-ui/src/atoms/SandboxToolCallCard.tsx
index 1844223fe..772826b73 100644
--- a/packages/trueforge-ui/src/atoms/SandboxToolCallCard.tsx
+++ b/packages/trueforge-ui/src/atoms/SandboxToolCallCard.tsx
@@ -1,5 +1,7 @@
'use client';
+import type { ReactNode } from 'react';
+
import { Icon } from '../icons/Icon.js';
import { useSlot } from '../theme/SlotsProvider.js';
import { cn } from './lib/cn.js';
@@ -20,6 +22,9 @@ export type SandboxToolCallCardProps = {
hasContent?: boolean;
onViewModeChange?: (viewMode: 'terminal' | 'code') => void;
durationText?: string;
+ approvalSlot?: ReactNode;
+ /** When set, this card is the scroll/flash target for approval banner navigation. */
+ approvalId?: string;
dataTestPrefix?: string;
className?: string;
};
@@ -201,6 +206,8 @@ export function SandboxToolCallCard({
hasContent = false,
onViewModeChange,
durationText,
+ approvalSlot,
+ approvalId,
dataTestPrefix,
className,
}: SandboxToolCallCardProps) {
@@ -219,6 +226,8 @@ export function SandboxToolCallCard({
showResponseLine={false}
status={awaiting ? undefined : status}
exitCode={exitCode}
+ approvalSlot={approvalSlot}
+ approvalId={approvalId}
dataTestPrefix={dataTestPrefix}
requestSlot={
{
argsJson={argsJson}
resultText={resultText}
resultJson={resultJson}
+ approvalSlot={showApproval ? : undefined}
+ approvalId={showApproval ? part.approval?.id : undefined}
/>
);
}
@@ -261,6 +263,8 @@ export const ToolCallContainer: ToolCallMessagePartComponent = part => {
showResponseLine={status !== 'running' && !!resultDisplay.data}
mcpServerName={mcpServer}
{...slots}
+ approvalSlot={showApproval ? : undefined}
+ approvalId={showApproval ? part.approval?.id : undefined}
/>
);
}
diff --git a/packages/trueforge-ui/src/containers/approvalFocus.tsx b/packages/trueforge-ui/src/containers/approvalFocus.tsx
index 6605e4e2e..95217dcd6 100644
--- a/packages/trueforge-ui/src/containers/approvalFocus.tsx
+++ b/packages/trueforge-ui/src/containers/approvalFocus.tsx
@@ -64,16 +64,17 @@ export function ApprovalFocusProvider({ children }: { children: ReactNode }) {
const focus = useCallback(
(approvalId: string) => {
const ancestors = findSubAgentAncestorsForApproval(messages, approvalId);
- for (const toolCallId of ancestors) {
- expandsRef.current.get(toolCallId)?.();
- }
if (retryTimerRef.current != null) {
clearInterval(retryTimerRef.current);
retryTimerRef.current = null;
}
- const tryScroll = (): boolean => {
+ const tryFocus = (): boolean => {
+ for (const toolCallId of ancestors) {
+ expandsRef.current.get(toolCallId)?.();
+ }
+
const el = targetsRef.current.get(approvalId)?.() ?? null;
if (el == null) return false;
el.scrollIntoView?.({ block: 'center', behavior: 'smooth' });
@@ -81,12 +82,12 @@ export function ApprovalFocusProvider({ children }: { children: ReactNode }) {
return true;
};
- if (tryScroll()) return;
+ if (tryFocus()) return;
let attempts = 0;
retryTimerRef.current = setInterval(() => {
attempts += 1;
- if (tryScroll() || attempts >= MOUNT_RETRY_MAX) {
+ if (tryFocus() || attempts >= MOUNT_RETRY_MAX) {
if (retryTimerRef.current != null) {
clearInterval(retryTimerRef.current);
retryTimerRef.current = null;
diff --git a/packages/trueforge-ui/src/hooks/useApprovalNav.ts b/packages/trueforge-ui/src/hooks/useApprovalNav.ts
index b02df5bf6..7d4c2cf20 100644
--- a/packages/trueforge-ui/src/hooks/useApprovalNav.ts
+++ b/packages/trueforge-ui/src/hooks/useApprovalNav.ts
@@ -25,25 +25,23 @@ export type ApprovalNavState = {
export function useApprovalNav(): ApprovalNavState {
const { pending } = useTrueFoundryApprovals();
const focusApi = useOptionalApprovalFocus();
- const [index, setIndex] = useState(0);
- const prevCountRef = useRef(0);
+ const [selection, setSelection] = useState<{ approvalId: string | null; index: number }>({
+ approvalId: null,
+ index: 0,
+ });
const focusedIdRef = useRef(null);
const count = pending.length;
- const safeIndex = count === 0 ? 0 : Math.min(index, count - 1);
+ const matchedIndex =
+ selection.approvalId == null ? -1 : pending.findIndex(item => item.approvalId === selection.approvalId);
+ const safeIndex = matchedIndex >= 0 ? matchedIndex : count === 0 ? 0 : Math.min(selection.index, count - 1);
const currentId = pending[safeIndex]?.approvalId;
useEffect(() => {
- if (safeIndex !== index) setIndex(safeIndex);
- }, [safeIndex, index]);
-
- useEffect(() => {
- if (prevCountRef.current === 0 && count > 0) {
- setIndex(0);
- focusedIdRef.current = null;
- }
- prevCountRef.current = count;
- }, [count]);
+ const approvalId = currentId ?? null;
+ if (selection.approvalId === approvalId && selection.index === safeIndex) return;
+ setSelection({ approvalId, index: safeIndex });
+ }, [currentId, safeIndex, selection.approvalId, selection.index]);
useEffect(() => {
if (currentId == null || focusApi == null) {
@@ -56,12 +54,14 @@ export function useApprovalNav(): ApprovalNavState {
}, [currentId, focusApi]);
const goPrev = useCallback(() => {
- setIndex(i => Math.max(0, i - 1));
- }, []);
+ const index = Math.max(0, safeIndex - 1);
+ setSelection({ approvalId: pending[index]?.approvalId ?? null, index });
+ }, [pending, safeIndex]);
const goNext = useCallback(() => {
- setIndex(i => Math.min(Math.max(count - 1, 0), i + 1));
- }, [count]);
+ const index = Math.min(Math.max(count - 1, 0), safeIndex + 1);
+ setSelection({ approvalId: pending[index]?.approvalId ?? null, index });
+ }, [count, pending, safeIndex]);
const focusCurrent = useCallback(() => {
if (currentId == null || focusApi == null) return;
diff --git a/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx b/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx
index 090fc91a6..77c31d8c2 100644
--- a/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx
+++ b/packages/trueforge-ui/test/containers/ToolCallContainer.test.tsx
@@ -317,6 +317,40 @@ describe('ToolCallContainer', () => {
expect(screen.getByRole('button', { name: 'Deny' })).toBeInTheDocument();
});
+ it('shows the approval bar for a pending sandbox call', () => {
+ renderPendingApprovalMessage([
+ {
+ type: 'tool-call',
+ toolCallId: 'sandbox-1',
+ toolName: 'sandbox_exec',
+ args: {},
+ argsText: '{"command":"rm -rf output"}',
+ interrupt: { type: 'human', payload: {} },
+ approval: { id: 'sandbox-approval', approved: undefined },
+ },
+ ]);
+
+ expect(screen.getByRole('button', { name: 'Allow' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Deny' })).toBeInTheDocument();
+ });
+
+ it('shows the approval bar for a pending list_tools call', () => {
+ renderPendingApprovalMessage([
+ {
+ type: 'tool-call',
+ toolCallId: 'list-tools-1',
+ toolName: 'list_tools',
+ args: {},
+ argsText: '{"mcp_server":"github-team"}',
+ interrupt: { type: 'human', payload: {} },
+ approval: { id: 'list-tools-approval', approved: undefined },
+ },
+ ]);
+
+ expect(screen.getByRole('button', { name: 'Allow' })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: 'Deny' })).toBeInTheDocument();
+ });
+
it('calls onRespondToToolApproval when Allow is clicked', () => {
const onRespondToToolApproval = vi.fn();
renderPendingApprovalMessage(
diff --git a/packages/trueforge-ui/test/containers/approvalFocus.test.tsx b/packages/trueforge-ui/test/containers/approvalFocus.test.tsx
new file mode 100644
index 000000000..64ffb58c7
--- /dev/null
+++ b/packages/trueforge-ui/test/containers/approvalFocus.test.tsx
@@ -0,0 +1,110 @@
+// @vitest-environment jsdom
+import { act, fireEvent, render, screen } from '@testing-library/react';
+import { useRef, useState } from 'react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+const useAuiState = vi.hoisted(() => vi.fn());
+
+vi.mock('@assistant-ui/react', () => ({
+ useAuiState,
+}));
+
+import {
+ ApprovalFocusProvider,
+ useApprovalFocus,
+ useRegisterApprovalExpand,
+ useRegisterApprovalTarget,
+} from '@/containers/approvalFocus.js';
+
+const messages = [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool-call',
+ toolCallId: 'sub-outer',
+ toolName: 'create_sub_agent',
+ messages: [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool-call',
+ toolCallId: 'sub-inner',
+ toolName: 'create_sub_agent',
+ messages: [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool-call',
+ toolCallId: 'tool',
+ toolName: 'call_tool',
+ approval: { id: 'approval' },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+];
+
+function InnerTarget() {
+ const [expanded, setExpanded] = useState(false);
+ const targetRef = useRef(null);
+ useRegisterApprovalExpand('sub-inner', () => setExpanded(true));
+ useRegisterApprovalTarget('approval', () => targetRef.current);
+
+ return expanded ? approval target
: null;
+}
+
+function NestedTarget() {
+ const [expanded, setExpanded] = useState(false);
+ useRegisterApprovalExpand('sub-outer', () => setExpanded(true));
+ return expanded ? : null;
+}
+
+function FocusButton() {
+ const { focus } = useApprovalFocus();
+ return ;
+}
+
+describe('ApprovalFocusProvider', () => {
+ const scrollIntoView = vi.fn();
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.clearAllMocks();
+ useAuiState.mockReturnValue(messages);
+ Object.defineProperty(HTMLElement.prototype, 'scrollIntoView', {
+ configurable: true,
+ value: scrollIntoView,
+ });
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ Reflect.deleteProperty(HTMLElement.prototype, 'scrollIntoView');
+ });
+
+ it('expands newly mounted nested ancestors while retrying focus', () => {
+ render(
+
+
+
+ ,
+ );
+
+ fireEvent.click(screen.getByRole('button', { name: 'Focus approval' }));
+ act(() => vi.advanceTimersByTime(50));
+ act(() => vi.advanceTimersByTime(50));
+
+ expect(screen.getByText('approval target')).toBeInTheDocument();
+ expect(scrollIntoView).toHaveBeenCalledOnce();
+ });
+});
diff --git a/packages/trueforge-ui/test/hooks/useApprovalNav.test.tsx b/packages/trueforge-ui/test/hooks/useApprovalNav.test.tsx
new file mode 100644
index 000000000..a34e02a52
--- /dev/null
+++ b/packages/trueforge-ui/test/hooks/useApprovalNav.test.tsx
@@ -0,0 +1,71 @@
+// @vitest-environment jsdom
+import { act, renderHook } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+const useApprovals = vi.hoisted(() => vi.fn());
+const focus = vi.hoisted(() => vi.fn());
+
+vi.mock('@truefoundry/assistant-ui-runtime', () => ({
+ useTrueFoundryApprovals: useApprovals,
+}));
+
+vi.mock('@/containers/approvalFocus.js', () => ({
+ useOptionalApprovalFocus: () => ({ focus }),
+}));
+
+import { useApprovalNav } from '@/hooks/useApprovalNav.js';
+
+type PendingApproval = {
+ approvalId: string;
+};
+
+let pending: PendingApproval[];
+
+describe('useApprovalNav', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ pending = [];
+ useApprovals.mockImplementation(() => ({ pending }));
+ });
+
+ it('keeps the selected approval when an earlier approval resolves', () => {
+ pending = [{ approvalId: 'A' }, { approvalId: 'B' }, { approvalId: 'C' }];
+ const { result, rerender } = renderHook(() => useApprovalNav());
+
+ act(() => result.current.goNext());
+ expect(result.current.index).toBe(1);
+ expect(focus).toHaveBeenCalledTimes(2);
+
+ pending = [{ approvalId: 'B' }, { approvalId: 'C' }];
+ rerender();
+
+ expect(result.current.index).toBe(0);
+ expect(focus).toHaveBeenLastCalledWith('B');
+ expect(focus).toHaveBeenCalledTimes(2);
+ });
+
+ it('keeps the same slot when the selected approval resolves', () => {
+ pending = [{ approvalId: 'A' }, { approvalId: 'B' }, { approvalId: 'C' }];
+ const { result, rerender } = renderHook(() => useApprovalNav());
+
+ act(() => result.current.goNext());
+ pending = [{ approvalId: 'A' }, { approvalId: 'C' }];
+ rerender();
+
+ expect(result.current.index).toBe(1);
+ expect(focus).toHaveBeenLastCalledWith('C');
+ });
+
+ it('starts at the first approval and does not wrap', () => {
+ pending = [{ approvalId: 'A' }, { approvalId: 'B' }];
+ const { result } = renderHook(() => useApprovalNav());
+
+ expect(result.current.index).toBe(0);
+ act(() => result.current.goPrev());
+ expect(result.current.index).toBe(0);
+
+ act(() => result.current.goNext());
+ act(() => result.current.goNext());
+ expect(result.current.index).toBe(1);
+ });
+});
diff --git a/packages/trueforge-ui/test/utils/findApprovalAncestors.test.ts b/packages/trueforge-ui/test/utils/findApprovalAncestors.test.ts
index cde328fbf..1892a55f0 100644
--- a/packages/trueforge-ui/test/utils/findApprovalAncestors.test.ts
+++ b/packages/trueforge-ui/test/utils/findApprovalAncestors.test.ts
@@ -51,6 +51,48 @@ describe('findSubAgentAncestorsForApproval', () => {
expect(findSubAgentAncestorsForApproval(messages, 'appr-nested')).toEqual(['sub-1']);
});
+ it('returns nested sub-agent ancestors from outermost to innermost', () => {
+ const messages = [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool-call',
+ toolCallId: 'sub-outer',
+ toolName: 'create_sub_agent',
+ messages: [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool-call',
+ toolCallId: 'sub-inner',
+ toolName: 'create_sub_agent',
+ messages: [
+ {
+ role: 'assistant',
+ content: [
+ {
+ type: 'tool-call',
+ toolCallId: 'tc-nested',
+ toolName: 'call_tool',
+ approval: { id: 'appr-nested' },
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ],
+ },
+ ];
+
+ expect(findSubAgentAncestorsForApproval(messages, 'appr-nested')).toEqual(['sub-outer', 'sub-inner']);
+ });
+
it('ignores resolved approvals', () => {
const messages = [
{
From ea55f915dab70b815dcae59af9b5d79403ea6f4e Mon Sep 17 00:00:00 2001
From: Govinda Vashishtha <57435703+govindavashishtha@users.noreply.github.com>
Date: Thu, 27 Aug 2026 20:09:42 +0530
Subject: [PATCH 3/3] Update ToolCallCard styling for approval states to
maintain layout stability during flashing
---
packages/trueforge-ui/src/atoms/ToolCallCard.tsx | 5 +++--
1 file changed, 3 insertions(+), 2 deletions(-)
diff --git a/packages/trueforge-ui/src/atoms/ToolCallCard.tsx b/packages/trueforge-ui/src/atoms/ToolCallCard.tsx
index 08b3d821e..90737a819 100644
--- a/packages/trueforge-ui/src/atoms/ToolCallCard.tsx
+++ b/packages/trueforge-ui/src/atoms/ToolCallCard.tsx
@@ -86,8 +86,9 @@ export function ToolCallCard({
data-approval-id={approvalId}
className={cn(
'aui-tool-call-card flex min-w-0 flex-col',
- // Keep box model stable while flashing — only the background animates.
- highlightCard ? '-mx-1 rounded-md p-1' : 'mx-0 mt-2 p-0',
+ // Pending-approval cards keep top/right padding always so flash doesn't jump the layout.
+ // Left stays unpadded so the step rail stays aligned with siblings.
+ approvalId != null || highlightCard ? 'mx-0 mt-2 rounded-md pt-1.5 pr-2 pb-1' : 'mx-0 mt-2 p-0',
isFlashing && 'aui-approval-flash',
className,
)}