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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/pre/approval-nav-banner.md
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
govindavashishtha marked this conversation as resolved.
87 changes: 87 additions & 0 deletions packages/trueforge-ui/src/atoms/ApprovalNavBanner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
'use client';

import { Icon } from '../icons/Icon.js';
import { cn } from './lib/cn.js';

export type ApprovalNavBannerProps = {
count: number;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
count: number;
total: 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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

same

const label = count === 1 ? '1 tool needs your input' : `${String(count)} tools need your input`;

return (
<div
data-slot="aui_approval-nav-banner"
role="status"
aria-live="polite"
aria-label={`${label}. Click to go to approval ${String(current)} of ${String(count)}.`}
onClick={onFocusCurrent}
className={cn(
// Figma light 6747:4232 / dark 6748:1977 — top border only; sides & bottom open into the composer.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can remove comment, not useful

'aui-approval-nav-banner border-approval-banner-border bg-approval-banner-bg text-approval-banner-fg flex w-full cursor-pointer items-center justify-between gap-2 border-t px-4 py-1.5 text-sm',
'rounded-t-[var(--composer-radius,1.5rem)]',
className,
)}
>
<div className="flex min-w-0 items-center gap-3">
<span className="bg-approval-banner-accent size-2 shrink-0 rounded-full" aria-hidden />
<span className="truncate font-medium leading-[1.4]">{label}</span>
</div>
<div className="flex shrink-0 items-center gap-2">
<span className="font-medium tabular-nums leading-[1.4]">
({current}/{count})
</span>
<button
type="button"
aria-label="Next approval"
disabled={!canNext}
onClick={event => {
event.stopPropagation();
onNext();
}}
className="text-approval-banner-fg hover:bg-approval-banner-border/10 inline-flex size-6 items-center justify-center rounded-sm disabled:pointer-events-none disabled:opacity-40"
>
<Icon name="arrow-down" size="0.875rem" />
</button>
<button
type="button"
aria-label="Previous approval"
disabled={!canPrev}
onClick={event => {
event.stopPropagation();
onPrev();
}}
className="text-approval-banner-fg hover:bg-approval-banner-border/10 inline-flex size-6 items-center justify-center rounded-sm disabled:pointer-events-none disabled:opacity-40"
>
<Icon name="arrow-up" size="0.875rem" />
</button>
</div>
</div>
);
}

declare module '../theme/SlotsProvider.js' {
interface AtomSlots {
ApprovalNavBanner: typeof ApprovalNavBanner;
}
}
9 changes: 9 additions & 0 deletions packages/trueforge-ui/src/atoms/SandboxToolCallCard.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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;
};
Expand Down Expand Up @@ -201,6 +206,8 @@ export function SandboxToolCallCard({
hasContent = false,
onViewModeChange,
durationText,
approvalSlot,
approvalId,
dataTestPrefix,
className,
}: SandboxToolCallCardProps) {
Expand All @@ -219,6 +226,8 @@ export function SandboxToolCallCard({
showResponseLine={false}
status={awaiting ? undefined : status}
exitCode={exitCode}
approvalSlot={approvalSlot}
approvalId={approvalId}
dataTestPrefix={dataTestPrefix}
requestSlot={
<SandboxBody
Expand Down
18 changes: 16 additions & 2 deletions packages/trueforge-ui/src/atoms/ToolCallCard.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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;
Expand All @@ -46,18 +49,24 @@ export function ToolCallCard({
showExpandChevron = true,
showResponseLine = false,
approvalSlot,
approvalId,
requestSlot,
responseSlot,
highlightCard = false,
className,
mcpServerName: _mcpServerName,
dataTestPrefix,
}: ToolCallCardProps) {
const rootRef = useRef<HTMLDivElement>(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;
Expand All @@ -73,9 +82,14 @@ export function ToolCallCard({

return (
<div
ref={rootRef}
data-approval-id={approvalId}
className={cn(
'aui-tool-call-card flex min-w-0 flex-col',
highlightCard ? 'rounded-md p-1 -mx-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.
Comment on lines +89 to +90

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

can remove

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,
)}
data-testid={dataTestPrefix ? `${dataTestPrefix}-tool-call-card` : undefined}
Expand Down
24 changes: 24 additions & 0 deletions packages/trueforge-ui/src/containers/ApprovalNavContainer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
'use client';

import { useApprovalNav } from '../hooks/useApprovalNav.js';
import { useSlot } from '../theme/SlotsProvider.js';

/** Composer banner that navigates pending tool approvals. */
export function ApprovalNavContainer() {
const ApprovalNavBanner = useSlot('ApprovalNavBanner');
const { count, index, canPrev, canNext, goPrev, goNext, focusCurrent } = useApprovalNav();

if (count === 0) return null;

return (
<ApprovalNavBanner
count={count}
current={index + 1}
canPrev={canPrev}
canNext={canNext}
onPrev={goPrev}
onNext={goNext}
onFocusCurrent={focusCurrent}
/>
);
}
82 changes: 60 additions & 22 deletions packages/trueforge-ui/src/containers/ComposerContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();
Expand All @@ -30,7 +40,8 @@ function ComposerBody({ placeholder }: { placeholder: string }) {
const { isBusy, send, resetBusy } = useComposerBusyState();
const cancel = useTrueFoundryCancel();
const fileInputRef = useRef<HTMLInputElement>(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());
Expand All @@ -54,7 +65,7 @@ function ComposerBody({ placeholder }: { placeholder: string }) {
}}
/>
<ComposerPrimitive.AttachmentDropzone
disabled={isBusy}
disabled={disabled}
data-slot="aui_composer-attachment-dropzone"
className="w-full rounded-[var(--composer-radius,1.5rem)] transition-[box-shadow] data-[dragging=true]:ring-focus-ring/20 data-[dragging=true]:ring-3"
>
Expand All @@ -68,20 +79,26 @@ function ComposerBody({ placeholder }: { placeholder: string }) {
}}
>
<ComposerShell
className={
connectedToBanner
? // Banner owns the top accent; composer keeps its normal chrome without a shared blue frame.
'rounded-t-none border-t-0 focus-within:ring-0'
: undefined
}
attachments={<ComposerAttachmentsContainer />}
input={
<ComposerPrimitive.Input
data-slot="aui_composer-input"
placeholder={placeholder}
disabled={isBusy}
disabled={disabled}
submitMode="enter"
aria-label="Message input"
className="text-text-primary placeholder:text-text-secondary/80 max-h-[10lh] min-h-10 w-full resize-none overflow-y-auto rounded-lg border-none bg-transparent px-1 py-1 text-base leading-normal shadow-none outline-none disabled:cursor-not-allowed"
/>
}
disabled={isBusy}
disabled={disabled}
canSubmit={canSubmit}
isRunning={isBusy}
isRunning={isBusy && !forceDisabled}
onSubmit={submit}
onCancel={() => {
resetBusy();
Expand All @@ -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');
Expand All @@ -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 <McpAuthContainer />;
}
if (pauseView.kind === 'custom') {
return <CustomActionContainer />;
}
if (pauseView.kind === 'ask-user') {
return <AskUserContainer />;
}

if (canMutateSpec) {
return (
<DraftCatalogProvider>
Expand All @@ -127,11 +139,37 @@ export function ComposerContainer({
ComposerRightSection: usesDefaultRightSection ? DraftComposerRightSection : parentRightSection,
}}
>
<ComposerBody placeholder={placeholder} />
<ComposerBody placeholder={placeholder} forceDisabled={forceDisabled} connectedToBanner={connectedToBanner} />
</SlotsProvider>
</DraftCatalogProvider>
);
}

return <ComposerBody placeholder={placeholder} />;
return <ComposerBody placeholder={placeholder} forceDisabled={forceDisabled} connectedToBanner={connectedToBanner} />;
}

export function ComposerContainer({
placeholder = 'Ask anything... (Shift+Enter for new line)',
}: ComposerContainerProps) {
const pauseView = useComposerPauseView();

if (pauseView.kind === 'mcp') {
return <McpAuthContainer />;
}
if (pauseView.kind === 'custom') {
return <CustomActionContainer />;
}
if (pauseView.kind === 'ask-user') {
return <AskUserContainer />;
}
if (pauseView.kind === 'approval') {
return (
<div data-slot="aui_composer-approval-pause" className="flex w-full flex-col">
<ApprovalNavContainer />
<ComposerWithOptionalDraft placeholder={placeholder} forceDisabled connectedToBanner />
</div>
);
}

return <ComposerWithOptionalDraft placeholder={placeholder} />;
}
Loading