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
6 changes: 3 additions & 3 deletions apps/agent-forge/e2e/a11y.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,9 +49,9 @@ test('drawer and rail tabs are tablists driven by the arrow keys', async ({ page
await openBuilder(page);
const drawerTabs = page.getByRole('tablist', { name: 'Run panels' });
await expect(drawerTabs.getByRole('tab')).toHaveCount(6);
const chat = drawerTabs.getByRole('tab', { name: 'Chat' });
await expect(chat).toHaveAttribute('aria-selected', 'true');
await chat.focus();
const testTab = drawerTabs.getByRole('tab', { name: 'Test' });
await expect(testTab).toHaveAttribute('aria-selected', 'true');
await testTab.focus();
await page.keyboard.press('ArrowRight');
const logs = drawerTabs.getByRole('tab', { name: 'Logs' });
await expect(logs).toHaveAttribute('aria-selected', 'true');
Expand Down
12 changes: 6 additions & 6 deletions apps/agent-forge/src/canvas/__tests__/paletteActions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,18 +18,18 @@ describe('viewportCentrePosition', () => {
});

describe('tab keyboard model', () => {
const ids = ['chat', 'logs', 'trace'] as const;
const ids = ['test', 'logs', 'trace'] as const;

it('moves with the arrow keys, wrapping, and jumps with Home/End', () => {
expect(nextTabId(ids, 'chat', 'ArrowRight')).toBe('logs');
expect(nextTabId(ids, 'trace', 'ArrowRight')).toBe('chat');
expect(nextTabId(ids, 'chat', 'ArrowLeft')).toBe('trace');
expect(nextTabId(ids, 'logs', 'Home')).toBe('chat');
expect(nextTabId(ids, 'test', 'ArrowRight')).toBe('logs');
expect(nextTabId(ids, 'trace', 'ArrowRight')).toBe('test');
expect(nextTabId(ids, 'test', 'ArrowLeft')).toBe('trace');
expect(nextTabId(ids, 'logs', 'Home')).toBe('test');
expect(nextTabId(ids, 'logs', 'End')).toBe('trace');
expect(nextTabId(ids, 'logs', 'a')).toBeUndefined();
});

it('pairs each tab with its panel id', () => {
expect(tabIds('drawer', 'chat')).toEqual({ tab: 'drawer-tab-chat', panel: 'drawer-panel-chat' });
expect(tabIds('drawer', 'test')).toEqual({ tab: 'drawer-tab-test', panel: 'drawer-panel-test' });
});
});
19 changes: 10 additions & 9 deletions apps/agent-forge/src/components/BottomDrawer.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import type { CSSProperties, ReactElement } from 'react';
import { Activity, Braces, History, MessageSquare, ScrollText, Settings, type LucideIcon } from 'lucide-react';
import { Activity, Braces, FlaskConical, History, ScrollText, Settings, type LucideIcon } from 'lucide-react';
import { useAppState } from '../state/AppState';
import type { DrawerTab } from '../state/AppState';
import { LogsPanel } from './debug/LogsPanel';
import { TracePanel } from './debug/TracePanel';
import { OutputPanel } from './debug/OutputPanel';
import { HistoryPanel } from './debug/HistoryPanel';
import { DebugBar } from './debug/DebugBar';
import { ChatPanel } from './ChatPanel';
import { TestPane } from './test/TestPane';
import { SettingsPanel } from './SettingsPanel';
import { tabIds, tabListKeyDown } from './tabs';
import { DrawerResizeHandle, useDrawerHeight } from './drawerHeight';

const TABS: { id: DrawerTab; label: string; Icon: LucideIcon }[] = [
{ id: 'chat', label: 'Chat', Icon: MessageSquare },
{ id: 'test', label: 'Test', Icon: FlaskConical },
{ id: 'logs', label: 'Logs', Icon: ScrollText },
{ id: 'trace', label: 'Trace', Icon: Activity },
{ id: 'output', label: 'Output', Icon: Braces },
Expand All @@ -23,7 +23,7 @@ const TABS: { id: DrawerTab; label: string; Icon: LucideIcon }[] = [
const TAB_IDS = TABS.map((t) => t.id);

const TAB_PANELS: Record<DrawerTab, () => ReactElement> = {
chat: () => <ChatPanel />,
test: () => <TestPane />,
logs: () => <LogsPanel />,
trace: () => <TracePanel />,
output: () => <OutputPanel />,
Expand All @@ -32,16 +32,16 @@ const TAB_PANELS: Record<DrawerTab, () => ReactElement> = {
};

// Every tab except Output and History manages its own padding/scrolling.
const FLUSH_TABS: ReadonlySet<DrawerTab> = new Set<DrawerTab>(['logs', 'trace', 'chat', 'settings']);
const FLUSH_TABS: ReadonlySet<DrawerTab> = new Set<DrawerTab>(['logs', 'trace', 'test', 'settings']);
const FLUSH_BODY_STYLE: CSSProperties = { padding: 0, overflow: 'hidden' };

type DrawerCounts = Partial<Record<DrawerTab, number>>;

/** The live counts shown on the Chat (messages), Logs (lines) and Trace (spans) tabs. */
/** The live counts shown on the Test (messages), Logs (lines) and Trace (spans) tabs. */
function useDrawerCounts(): DrawerCounts {
const { chat, logs, spans } = useAppState();
return {
chat: chat.messages.filter((m) => m.role !== 'system').length,
test: chat.messages.filter((m) => m.role !== 'system').length,
logs: logs.length,
trace: spans.length,
};
Expand All @@ -62,8 +62,9 @@ function TabCount({ count }: { count: number | undefined }) {
* feed), `TracePanel` (the run's spans in the shared trace viewer) and `OutputPanel` (a
* collapsible JSON tree of the final/paused ExecutionResult). `DebugBar`
* surfaces above Trace whenever debug mode is on or a run is paused
* at a breakpoint. Chat and Settings are still later
* epics' jobs. Its top edge resizes it; the height is remembered per viewer
* at a breakpoint. The Test tab (TestPane: a run bar over the chat
* thread) and Settings are styled by their own panels. Its top edge
* resizes it; the height is remembered per viewer
* (see drawerHeight.tsx).
*/
export function BottomDrawer() {
Expand Down
4 changes: 2 additions & 2 deletions apps/agent-forge/src/components/ChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const MAX_LINES = 8;
function placeholderFor(viewing: boolean, isPausedForApproval: boolean): string {
if (viewing) return 'Return to the live chat to send a message';
if (isPausedForApproval) return 'Resolve the approval above to continue';
return 'Message this agent...';
return 'Message the agent to test it';
}

/** Fits the textarea's height to its content, capped at MAX_LINES lines. */
Expand All @@ -35,7 +35,7 @@ export interface ChatComposerProps {
}

/**
* The Chat tab's composer: a rounded card holding a textarea that grows
* The Test tab's composer: a rounded card holding a textarea that grows
* with its text (up to eight lines), the accent Send icon button, and a
* "Shift+Enter for newline" hint. Enter sends; Shift+Enter adds a line.
*/
Expand Down
13 changes: 10 additions & 3 deletions apps/agent-forge/src/components/ChatPanel.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/**
* Chat with a running agent - the Chat tab's real content, replacing
* Chat with a running agent - the Test tab's real content, replacing
* BottomDrawer's former placeholder. Renders `AppState.chat` (the server's
* real `Message[]` history, reconciled and streamed over the existing
* `WS /agents/:id/stream` channel - see runRegistry.ts/chatReconcile.ts)
Expand All @@ -15,6 +15,7 @@
* real `status: 'running'` from the runtime server rather than a fake timeout.
*/
import { useEffect, useRef } from 'react';
import type { ReactNode } from 'react';
import { Play, Plus, Sparkles, User, Wrench, type LucideIcon } from 'lucide-react';
import { useAppState } from '../state/AppState';
import type { ChatMessage } from '../../shared/wireTypes';
Expand Down Expand Up @@ -232,7 +233,7 @@ interface MessageListProps {

function EmptyChatNotice({ count, isRunning }: { count: number; isRunning: boolean }) {
return count === 0 && !isRunning ? (
<div className="chat-empty">Send a message to start chatting with this agent.</div>
<div className="chat-empty">Send a message to test this agent. Nodes light up on the canvas as it runs.</div>
) : null;
}

Expand All @@ -258,7 +259,11 @@ function MessageList({ messages, isRunning, pendingApproval }: MessageListProps)
);
}

export function ChatPanel() {
/**
* `aboveComposer` is the Test pane's middle slot (T05's steer box, T07's
* pending question): rendered between the error notice and the composer.
*/
export function ChatPanel({ aboveComposer }: { aboveComposer?: ReactNode }) {
const { chat, chatActionError, viewedChatSession, runStatus } = useAppState();

const viewing = viewedChatSession !== undefined;
Expand All @@ -277,6 +282,8 @@ export function ChatPanel() {
</div>
)}

{aboveComposer}

<ChatComposer
viewing={viewing}
isPausedForApproval={!!pendingApproval}
Expand Down
9 changes: 5 additions & 4 deletions apps/agent-forge/src/components/Topbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@ import { useAppState } from '../state/AppState';
import { downloadSpec, importSpecFile } from '../persistence/importExport';
import { ApprovalCard } from './ApprovalCard';
import { errorMessage } from './errorMessage';
import { RunStatus, lastRunTraceId } from './RunStatus';
import { RunStatus } from './RunStatus';
import { useOpenTrace } from './test/useOpenTrace';
import { ToolbarMoreMenu, type ToolbarMenuItem } from './ToolbarMoreMenu';
import { navigate } from '../router/router';
import { useSaveVersionAction } from '../screens/agents/useSaveVersionShortcut';
Expand Down Expand Up @@ -306,8 +307,8 @@ function RunOrStop({ isRunning, onRun, onStop }: { isRunning: boolean; onRun: ()

/** The narrow "More" menu: Debug, Import, Export, Observe surface and (once known) Open trace. */
function useNarrowMenuItems(openImport: () => void, exportSpec: () => void): ToolbarMenuItem[] {
const { runStatus, spans, debugMode, setDebugMode, setDrawerTab } = useAppState();
const traceId = lastRunTraceId(runStatus, spans);
const { debugMode, setDebugMode, setDrawerTab } = useAppState();
const { traceId, openTrace } = useOpenTrace();
return [
{
label: 'Debug',
Expand All @@ -325,7 +326,7 @@ function useNarrowMenuItems(openImport: () => void, exportSpec: () => void): Too
{ label: 'Observe surface', Icon: Eye, onSelect: () => navigate('/traces') },
// The wide bar's "Open trace" link, which has no room next to Run at 375px.
...(traceId
? [{ label: 'Open trace', Icon: ExternalLink, onSelect: () => navigate(`/traces/${encodeURIComponent(traceId)}`) }]
? [{ label: 'Open trace', Icon: ExternalLink, onSelect: openTrace }]
: []),
];
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ const appState = vi.hoisted(() => ({
selectedNodeId: undefined,
setSelectedNodeId: vi.fn(),
sendChatMessage: vi.fn(async () => {}),
drawerTab: 'chat',
drawerTab: 'test',
setDrawerTab: vi.fn(),
chat: {
sessionId: 's1',
Expand Down Expand Up @@ -142,10 +142,10 @@ describe('BottomDrawer tabs', () => {
it('show icons and counts while each tab keeps its bare label as its name', () => {
const container = render(<BottomDrawer />);
const tabs = [...container.querySelectorAll('[role="tab"]')];
expect(tabs.map(nameOf)).toEqual(['Chat', 'Logs', 'Trace', 'Output', 'History', 'Settings']);
expect(tabs.map(nameOf)).toEqual(['Test', 'Logs', 'Trace', 'Output', 'History', 'Settings']);
// The label is its own span (icon-only tabs at phone widths) and the tooltip.
expect(tabs.map((t) => [t.querySelector('.drawer-tab-label')?.textContent, t.getAttribute('title')])).toEqual(
['Chat', 'Logs', 'Trace', 'Output', 'History', 'Settings'].map((l) => [l, l]),
['Test', 'Logs', 'Trace', 'Output', 'History', 'Settings'].map((l) => [l, l]),
);
// System messages are not counted; an empty count is not drawn.
expect(tabs.map((t) => t.querySelector('.count')?.textContent ?? '')).toEqual(['2', '3', '', '', '', '']);
Expand Down
27 changes: 25 additions & 2 deletions apps/agent-forge/src/components/__tests__/chatPanel.test.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { render } from '../../ui/__tests__/render';
import type { ChatMessage } from '../../../shared/wireTypes';

Expand All @@ -19,9 +19,10 @@ const MESSAGES: ChatMessage[] = [
{ id: 'a2', role: 'assistant', content: 'This is a mock response.', timestamp: at },
];

const state = vi.hoisted(() => ({ messages: [] as ChatMessage[] }));
vi.mock('../../state/AppState', () => ({
useAppState: () => ({
chat: { sessionId: 's1', messages: MESSAGES },
chat: { sessionId: 's1', messages: state.messages },
chatSessions: [],
viewedChatSession: undefined,
runStatus: undefined,
Expand All @@ -38,6 +39,28 @@ import { logTitle } from '../debug/LogsPanel';
import { stepTone } from '../debug/TonePill';

describe('ChatPanel', () => {
beforeEach(() => {
state.messages = MESSAGES;
});

it('shows the test empty-state copy when the thread has no messages', () => {
state.messages = [];
// jsdom has no scrolling; the thread scrolls itself to the newest message.
Element.prototype.scrollTo = () => {};
const container = render(<ChatPanel />);
expect(container.querySelector('.chat-empty')?.textContent).toBe(
'Send a message to test this agent. Nodes light up on the canvas as it runs.',
);
});

it('renders aboveComposer between the thread and the composer', () => {
Element.prototype.scrollTo = () => {};
const container = render(<ChatPanel aboveComposer={<div className="test-slot">steer box</div>} />);
const slot = container.querySelector('.test-slot')!;
expect(slot.previousElementSibling?.classList.contains('chat-messages')).toBe(true);
expect(slot.nextElementSibling?.classList.contains('chat-composer')).toBe(true);
});

it('draws a paired tool result under its call, an unpaired one as a tool block, and names every sender', () => {
// jsdom has no scrolling; the thread scrolls itself to the newest message.
Element.prototype.scrollTo = () => {};
Expand Down
26 changes: 26 additions & 0 deletions apps/agent-forge/src/components/test/TestPane.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
import type { ReactNode } from 'react';
import { ChatPanel } from '../ChatPanel';
import { TestRunBar } from './TestRunBar';
import './test.css';

export interface TestPaneSlots {
/** Right side of the run bar (T06 puts Stop here). */
runActions?: ReactNode;
/** Between the thread and the composer (T05 steer box, T07 pending question). */
aboveComposer?: ReactNode;
}

/**
* The builder drawer's Test tab: a run bar (status, model, elapsed time, Open
* trace) above the chat thread and composer. Later P6 tickets fill the two
* slots - T05's steer box and T07's pending question go `aboveComposer`, T06's
* Stop goes `runActions`.
*/
export function TestPane({ runActions, aboveComposer }: TestPaneSlots) {
return (
<div className="test-pane">
<TestRunBar actions={runActions} />
<ChatPanel aboveComposer={aboveComposer} />
</div>
);
}
41 changes: 41 additions & 0 deletions apps/agent-forge/src/components/test/TestRunBar.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import type { ReactNode } from 'react';
import { ExternalLink } from 'lucide-react';
import { useAppState } from '../../state/AppState';
import { RunStatus } from '../RunStatus';
import { useRunElapsed } from './useRunElapsed';

/**
* The Test pane's run bar: the live run's status pill, the provider/model it
* reported, a ticking elapsed clock while a run is in flight, and - once the
* status carries a trace id - the same "Open trace" link the Topbar shows.
* `actions` is the slot later tickets fill (T06's Stop lives here); it sits
* at the bar's right end.
*/
export function TestRunBar({ actions }: { actions?: ReactNode }) {
const { runStatus } = useAppState();
const elapsed = useRunElapsed(runStatus);
const traceId = runStatus?.traceId;
return (
<div className="test-run-bar" role="group" aria-label="Test run">
<RunStatus showTraceLink={false} />
{runStatus?.provider && <span className="test-run-model">{runStatus.provider}</span>}
{elapsed !== undefined && (
<span className="test-run-elapsed" role="timer" aria-label={`Running for ${elapsed}`}>
{elapsed}
</span>
)}
{traceId !== undefined && (
<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>
)}
{actions !== undefined && <span className="test-run-actions">{actions}</span>}
</div>
);
}
Loading
Loading