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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions apps/agent-forge/e2e/docShots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,30 @@ test('agents x: the builder toolbar at 860 and 375 wide', async ({ page }) => {
await shoot(page, 'x-agents-toolbar-375-menu');
});

test('agents x: the builder toolbar with a pending approval at 1280, 1024 and 375 wide', async ({ page }) => {
test.setTimeout(60_000);
await newAgent(page, `pending-${Date.now()}`, 'support-bot');
await page.locator('[data-node-type="tool"]').click();
await page.locator('#node-tool-name').fill('demo-approval');
await page.locator('.chat-input').fill('Please call demo-approval to look something up for me.');
await page.locator('.btn-send').click();
await expect(page.locator('.chat-approval-card')).toBeVisible({ timeout: 15_000 });
for (const width of [1280, 1024, 375]) {
await page.setViewportSize({ width, height: 800 });
await expect(page.locator('.topbar-builder')).toBeVisible();
await shoot(page, `x-agents-toolbar-pending-${width}`);
if (width === 1024) {
// the icon-only link's focus ring, reached by keyboard, inside the left group's clip
const trace = page.locator('.topbar-builder').getByRole('link', { name: 'Open trace', exact: true });
await trace.focus();
await page.keyboard.press('Shift+Tab');
await page.keyboard.press('Tab');
await expect(trace).toBeFocused();
await shoot(page, 'x-agents-toolbar-pending-1024-focus');
}
}
});

test("agents x: the routed settings screen shares the drawer's settings cards", async ({ page }) => {
await open(page, '/settings');
await expect(page.getByLabel('Deploy adapter')).toBeVisible();
Expand Down
41 changes: 41 additions & 0 deletions apps/agent-forge/e2e/studio.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,47 @@ test('create an agent, hit an approval gate via chat, approve it, and complete t
await expect(page.locator('.chat-msg.agent').last()).toBeVisible();
});

/** `inner` is drawn whole inside `outer` (not clipped by its overflow). */
async function expectInside(inner: ReturnType<Page['locator']>, outer: ReturnType<Page['locator']>) {
const [a, b] = [await inner.boundingBox(), await outer.boundingBox()];
expect(a && b).toBeTruthy();
expect(a!.width).toBeGreaterThan(0);
expect(a!.x).toBeGreaterThanOrEqual(b!.x - 0.5);
expect(a!.x + a!.width).toBeLessThanOrEqual(b!.x + b!.width + 0.5);
}

/**
* R2: with a tool approval pending, the wide builder toolbar still links to the
* run's trace (icon-only once the bar is short) and keeps the MOCK badge next to
* the inline approval, at 1280 and at 1024 wide.
*/
test('a pending approval keeps "Open trace" and the MOCK badge in the toolbar at 1280 and 1024 wide', async ({ page }) => {
const agentId = `e2e-pending-trace-${Date.now()}`;
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto('/?token=e2e-studio-token');
await page.getByRole('button', { name: 'New agent', exact: true }).click();
await page.locator('#new-agent-name').fill(agentId);
await page.locator('#new-agent-template').selectOption('support-bot');
await page.getByRole('button', { name: 'Create', exact: true }).click();
await page.locator('[data-node-type="tool"]').click();
await page.locator('#node-tool-name').fill('demo-approval');
await page.locator('.chat-input').fill('Please call demo-approval to look something up for me.');
await page.locator('.btn-send').click();

const toolbar = page.locator('.topbar-builder');
await expect(toolbar.locator('.approval-card-inline')).toBeVisible({ timeout: 15_000 });
for (const width of [1280, 1024]) {
await page.setViewportSize({ width, height: 800 });
const trace = toolbar.getByRole('link', { name: 'Open trace', exact: true });
await expect(trace).toBeVisible();
await expect(trace).toHaveAttribute('href', /^#\/traces\/[^/]+$/);
await expectInside(trace, toolbar.locator('.topbar-left'));
const mock = toolbar.locator('.topbar-center .mock-badge');
await expect(mock).toHaveText('MOCK');
await expectInside(mock, toolbar.locator('.topbar-center'));
}
});

/** F5: the builder is the Agents screen, and `#/agents/:id` names the open agent. */
async function createAgent(page: Page, id: string) {
await page.getByRole('button', { name: 'New agent', exact: true }).click();
Expand Down
16 changes: 11 additions & 5 deletions apps/agent-forge/src/components/ApprovalCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,23 @@ interface DecisionButtonProps {
busyLabel: string;
busy: boolean;
disabled: boolean;
/** The toolbar's inline card: its buttons may go icon-only, so they carry their label as a tooltip. */
tooltip: boolean;
onClick: () => void;
}

function DecisionButton({ className, Icon, idleLabel, busyLabel, busy, disabled, onClick }: DecisionButtonProps) {
function DecisionButton({ className, Icon, idleLabel, busyLabel, busy, disabled, tooltip, onClick }: DecisionButtonProps) {
const label = busy ? busyLabel : idleLabel;
return (
<button type="button" className={className} onClick={onClick} disabled={disabled}>
<button type="button" className={className} title={tooltip ? label : undefined} onClick={onClick} disabled={disabled}>
<Icon size={16} strokeWidth={1.75} aria-hidden="true" />
{busy ? busyLabel : idleLabel}
{/* The toolbar's inline card keeps only the icon on a short bar (layout.css); the label stays the name. */}
<span className="approval-btn-label">{label}</span>
</button>
);
}

function DecisionButtons({ size }: { size: string }) {
function DecisionButtons({ size, tooltip = false }: { size: string; tooltip?: boolean }) {
const { error, pending, decide } = useApprovalDecision();
return (
<div className="approval-actions">
Expand All @@ -77,6 +81,7 @@ function DecisionButtons({ size }: { size: string }) {
busyLabel="Approving..."
busy={pending === 'approve'}
disabled={!!pending}
tooltip={tooltip}
onClick={() => void decide('approve')}
/>
<DecisionButton
Expand All @@ -86,6 +91,7 @@ function DecisionButtons({ size }: { size: string }) {
busyLabel="Rejecting..."
busy={pending === 'reject'}
disabled={!!pending}
tooltip={tooltip}
onClick={() => void decide('reject')}
/>
{error && <span className="run-error">{error}</span>}
Expand All @@ -105,7 +111,7 @@ export function ApprovalCard({ toolName, args, className, variant = 'card' }: Ap
<b className="approval-tool">{toolName}</b>
<span className="visually-hidden">?</span>
</span>
<DecisionButtons size=" btn-sm" />
<DecisionButtons size=" btn-sm" tooltip />
</div>
);
}
Expand Down
14 changes: 12 additions & 2 deletions apps/agent-forge/src/components/RunStatus.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ExternalLink } from 'lucide-react';
import { useAppState } from '../state/AppState';
import { StatusPill } from '../ui/StatusPill';
import type { AgentRunStatusPayload, SpanEvent } from '../../shared/wireTypes';
Expand All @@ -15,6 +16,9 @@ export function lastRunTraceId(runStatus: AgentRunStatusPayload | undefined, spa
* Next to Run: the last run's status (idle before any) and, once its trace id
* is known, an "Open trace" link to `#/traces/<traceId>` - builder runs report
* to the observe daemon (server/observeReporting.ts), so the trace screen has it.
* When the wide bar runs short (layout.css's builder-toolbar container rules)
* the label collapses and the link keeps only its icon; `aria-label` keeps the
* name "Open trace" and `title` is its tooltip at every width.
*/
export function RunStatus({ showTraceLink = true }: { showTraceLink?: boolean }) {
const { runStatus, spans } = useAppState();
Expand All @@ -23,8 +27,14 @@ export function RunStatus({ showTraceLink = true }: { showTraceLink?: boolean })
<>
<StatusPill status={runStatus?.status ?? 'idle'} live />
{traceId && (
<a className="run-trace-link" href={`#/traces/${encodeURIComponent(traceId)}`} title="Open this run's trace">
Open trace
<a
className="run-trace-link"
href={`#/traces/${encodeURIComponent(traceId)}`}
aria-label="Open trace"
title="Open this run's trace"
>
<ExternalLink size={14} strokeWidth={1.75} aria-hidden="true" />
<span className="run-trace-label">Open trace</span>
</a>
)}
</>
Expand Down
19 changes: 17 additions & 2 deletions apps/agent-forge/src/components/Topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,22 @@ function ModelBadge() {
);
}

/**
* The wide bar's centre: the model badge, or - while a run waits on a tool
* approval - the approval in its place, with the MOCK badge (same source)
* kept beside it on a mock agent.
*/
function CenterSlot({ runStatus }: { runStatus: RunStatusPayload }) {
const provider = useModelProvider();
if (!pendingApprovalOf(runStatus)) return <ModelBadge />;
return (
<>
{isMockProvider(provider) && <span className="mock-badge">MOCK</span>}
<ApprovalNotice runStatus={runStatus} />
</>
);
}

function DebugToggle() {
const { debugMode, setDebugMode, setDrawerTab } = useAppState();
return (
Expand Down Expand Up @@ -381,9 +397,8 @@ function WideTopbar() {
<ErrorNotice message={runErrorOf(runStatus)} />
<ErrorNotice message={actionError} />
</div>
{/* While a run waits on a tool approval, the approval takes the model badge's place. */}
<div className="topbar-center">
{pendingApprovalOf(runStatus) ? <ApprovalNotice runStatus={runStatus} /> : <ModelBadge />}
<CenterSlot runStatus={runStatus} />
</div>
<div className="topbar-spacer" />
<div className="topbar-actions">
Expand Down
10 changes: 10 additions & 0 deletions apps/agent-forge/src/components/__tests__/RunStatus.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,16 @@ describe('RunStatus', () => {
expect(link(container)?.textContent).toBe('Open trace');
});

it('carries an ExternalLink icon, a collapsible label and the name "Open trace" with a tooltip', () => {
appState.runStatus = status('paused', { traceId: 'trace-7' });
const container = render(<RunStatus />);
const a = link(container);
expect(a?.getAttribute('aria-label')).toBe('Open trace');
expect(a?.getAttribute('title')).toBe("Open this run's trace");
expect(a?.querySelector('svg.lucide-external-link')?.getAttribute('aria-hidden')).toBe('true');
expect(a?.querySelector('.run-trace-label')?.textContent).toBe('Open trace');
});

it('falls back to the live root span while the status has no trace id yet', () => {
appState.runStatus = status('running');
appState.spans = [
Expand Down
16 changes: 16 additions & 0 deletions apps/agent-forge/src/components/__tests__/agentsRails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ describe('ApprovalCard', () => {
expect(card.querySelector('.approval-tool')?.textContent).toBe('demo-approval');
expect(card.querySelector('.approval-args')?.textContent).toContain('mock input');
expect([...card.querySelectorAll('.approval-actions button')].map(nameOf)).toEqual(['Approve', 'Reject']);
// only the toolbar's inline card (whose buttons may go icon-only) carries tooltips
expect(card.querySelector('.approval-actions button[title]')).toBeNull();
act(() => (card.querySelector('.approval-actions button') as HTMLButtonElement).click());
expect(appState.approveAgent).toHaveBeenCalledWith(true);
});
Expand All @@ -66,6 +68,20 @@ describe('ApprovalCard', () => {
expect(card.querySelector('.approval-tool')?.textContent).toBe('http');
expect(card.querySelector('.approval-text')?.textContent).toBe('Approve http?');
expect(card.querySelector('.approval-args')).toBeNull();
const buttons = [...card.querySelectorAll('.approval-actions button')];
expect(buttons.map((b) => b.getAttribute('title'))).toEqual(['Approve', 'Reject']);
expect(buttons.map(nameOf)).toEqual(['Approve', 'Reject']);
});

it("the inline variant's tooltip follows the busy label", async () => {
let resolve: () => void = () => undefined;
appState.approveAgent.mockImplementationOnce(() => new Promise<void>((r) => (resolve = r)));
const container = render(<ApprovalCard variant="inline" toolName="http" args={{}} />);
const approve = container.querySelector('.approval-actions button') as HTMLButtonElement;
act(() => approve.click());
expect(approve.getAttribute('title')).toBe('Approving...');
await act(async () => resolve());
expect(approve.getAttribute('title')).toBe('Approve');
});
});

Expand Down
Loading
Loading