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/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={ (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,14 @@ 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..eecd46d78 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)) { @@ -232,6 +236,8 @@ export const ToolCallContainer: ToolCallMessagePartComponent = part => { argsJson={argsJson} resultText={resultText} resultJson={resultJson} + approvalSlot={showApproval ? : undefined} + approvalId={showApproval ? part.approval?.id : undefined} /> ); } @@ -257,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} /> ); } @@ -279,6 +287,7 @@ export const ToolCallContainer: ToolCallMessagePartComponent = part => { mcpServerName={mcpServer} {...slots} approvalSlot={showApproval ? : undefined} + approvalId={showApproval ? part.approval?.id : undefined} /> ); } @@ -300,6 +309,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..95217dcd6 --- /dev/null +++ b/packages/trueforge-ui/src/containers/approvalFocus.tsx @@ -0,0 +1,148 @@ +'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); + + if (retryTimerRef.current != null) { + clearInterval(retryTimerRef.current); + retryTimerRef.current = null; + } + + 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' }); + startFlash(approvalId); + return true; + }; + + if (tryFocus()) return; + + let attempts = 0; + retryTimerRef.current = setInterval(() => { + attempts += 1; + if (tryFocus() || 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..7d4c2cf20 --- /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 [selection, setSelection] = useState<{ approvalId: string | null; index: number }>({ + approvalId: null, + index: 0, + }); + const focusedIdRef = useRef(null); + + const count = pending.length; + 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(() => { + 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) { + if (currentId == null) focusedIdRef.current = null; + return; + } + if (focusedIdRef.current === currentId) return; + focusedIdRef.current = currentId; + focusApi.focus(currentId); + }, [currentId, focusApi]); + + const goPrev = useCallback(() => { + const index = Math.max(0, safeIndex - 1); + setSelection({ approvalId: pending[index]?.approvalId ?? null, index }); + }, [pending, safeIndex]); + + const goNext = useCallback(() => { + 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; + 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/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/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/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/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..1892a55f0 --- /dev/null +++ b/packages/trueforge-ui/test/utils/findApprovalAncestors.test.ts @@ -0,0 +1,125 @@ +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('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 = [ + { + 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([]); + }); +});