From 5bd40ad50d56905dc11a5428d8842a4a806e5cb1 Mon Sep 17 00:00:00 2001 From: Ali Mohammad Date: Mon, 9 Nov 2026 01:44:56 +0300 Subject: [PATCH] feat(studio): T01 test pane The builder bottom drawer's first tab is now Test (was Chat): a run bar (status pill, provider/model, ticking elapsed clock while running, Open trace once the status carries a traceId) above the chat thread and composer, with runActions/aboveComposer slots for T05-T07. - DrawerTab 'chat' renamed to 'test' (default 'test'); tab count keeps counting non-system chat messages - New src/components/test/: TestPane, TestRunBar, useRunElapsed, useOpenTrace (extracted from the Topbar narrow menu, reused there) - ChatPanel accepts aboveComposer; new empty-state copy; composer placeholder 'Message the agent to test it' - e2e a11y spec updated for the renamed tab --- apps/agent-forge/e2e/a11y.spec.ts | 6 +- .../canvas/__tests__/paletteActions.test.ts | 12 +- .../src/components/BottomDrawer.tsx | 19 +-- .../src/components/ChatComposer.tsx | 4 +- apps/agent-forge/src/components/ChatPanel.tsx | 13 +- apps/agent-forge/src/components/Topbar.tsx | 9 +- .../components/__tests__/agentsRails.test.tsx | 6 +- .../components/__tests__/chatPanel.test.tsx | 27 +++- .../src/components/test/TestPane.tsx | 26 ++++ .../src/components/test/TestRunBar.tsx | 41 ++++++ .../test/__tests__/TestPane.test.tsx | 132 ++++++++++++++++++ apps/agent-forge/src/components/test/test.css | 50 +++++++ .../src/components/test/useOpenTrace.ts | 19 +++ .../src/components/test/useRunElapsed.ts | 37 +++++ apps/agent-forge/src/state/AppState.tsx | 8 +- 15 files changed, 373 insertions(+), 36 deletions(-) create mode 100644 apps/agent-forge/src/components/test/TestPane.tsx create mode 100644 apps/agent-forge/src/components/test/TestRunBar.tsx create mode 100644 apps/agent-forge/src/components/test/__tests__/TestPane.test.tsx create mode 100644 apps/agent-forge/src/components/test/test.css create mode 100644 apps/agent-forge/src/components/test/useOpenTrace.ts create mode 100644 apps/agent-forge/src/components/test/useRunElapsed.ts diff --git a/apps/agent-forge/e2e/a11y.spec.ts b/apps/agent-forge/e2e/a11y.spec.ts index 64a90deb3..708c231af 100644 --- a/apps/agent-forge/e2e/a11y.spec.ts +++ b/apps/agent-forge/e2e/a11y.spec.ts @@ -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'); diff --git a/apps/agent-forge/src/canvas/__tests__/paletteActions.test.ts b/apps/agent-forge/src/canvas/__tests__/paletteActions.test.ts index c18a16bed..e54e0f8fa 100644 --- a/apps/agent-forge/src/canvas/__tests__/paletteActions.test.ts +++ b/apps/agent-forge/src/canvas/__tests__/paletteActions.test.ts @@ -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' }); }); }); diff --git a/apps/agent-forge/src/components/BottomDrawer.tsx b/apps/agent-forge/src/components/BottomDrawer.tsx index c211f6411..1649e7489 100644 --- a/apps/agent-forge/src/components/BottomDrawer.tsx +++ b/apps/agent-forge/src/components/BottomDrawer.tsx @@ -1,5 +1,5 @@ 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'; @@ -7,13 +7,13 @@ 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 }, @@ -23,7 +23,7 @@ const TABS: { id: DrawerTab; label: string; Icon: LucideIcon }[] = [ const TAB_IDS = TABS.map((t) => t.id); const TAB_PANELS: Record ReactElement> = { - chat: () => , + test: () => , logs: () => , trace: () => , output: () => , @@ -32,16 +32,16 @@ const TAB_PANELS: Record ReactElement> = { }; // Every tab except Output and History manages its own padding/scrolling. -const FLUSH_TABS: ReadonlySet = new Set(['logs', 'trace', 'chat', 'settings']); +const FLUSH_TABS: ReadonlySet = new Set(['logs', 'trace', 'test', 'settings']); const FLUSH_BODY_STYLE: CSSProperties = { padding: 0, overflow: 'hidden' }; type DrawerCounts = Partial>; -/** 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, }; @@ -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() { diff --git a/apps/agent-forge/src/components/ChatComposer.tsx b/apps/agent-forge/src/components/ChatComposer.tsx index 76da53b98..a456d8343 100644 --- a/apps/agent-forge/src/components/ChatComposer.tsx +++ b/apps/agent-forge/src/components/ChatComposer.tsx @@ -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. */ @@ -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. */ diff --git a/apps/agent-forge/src/components/ChatPanel.tsx b/apps/agent-forge/src/components/ChatPanel.tsx index a6fcab32e..ba4336a8a 100644 --- a/apps/agent-forge/src/components/ChatPanel.tsx +++ b/apps/agent-forge/src/components/ChatPanel.tsx @@ -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) @@ -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'; @@ -232,7 +233,7 @@ interface MessageListProps { function EmptyChatNotice({ count, isRunning }: { count: number; isRunning: boolean }) { return count === 0 && !isRunning ? ( -
Send a message to start chatting with this agent.
+
Send a message to test this agent. Nodes light up on the canvas as it runs.
) : null; } @@ -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; @@ -277,6 +282,8 @@ export function ChatPanel() { )} + {aboveComposer} + 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', @@ -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 }] : []), ]; } diff --git a/apps/agent-forge/src/components/__tests__/agentsRails.test.tsx b/apps/agent-forge/src/components/__tests__/agentsRails.test.tsx index 173601b59..6a418daf0 100644 --- a/apps/agent-forge/src/components/__tests__/agentsRails.test.tsx +++ b/apps/agent-forge/src/components/__tests__/agentsRails.test.tsx @@ -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', @@ -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(); 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', '', '', '', '']); diff --git a/apps/agent-forge/src/components/__tests__/chatPanel.test.tsx b/apps/agent-forge/src/components/__tests__/chatPanel.test.tsx index 57b163310..d991f39a8 100644 --- a/apps/agent-forge/src/components/__tests__/chatPanel.test.tsx +++ b/apps/agent-forge/src/components/__tests__/chatPanel.test.tsx @@ -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'; @@ -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, @@ -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(); + 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(steer box} />); + 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 = () => {}; diff --git a/apps/agent-forge/src/components/test/TestPane.tsx b/apps/agent-forge/src/components/test/TestPane.tsx new file mode 100644 index 000000000..352ec7b26 --- /dev/null +++ b/apps/agent-forge/src/components/test/TestPane.tsx @@ -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 ( +
+ + +
+ ); +} diff --git a/apps/agent-forge/src/components/test/TestRunBar.tsx b/apps/agent-forge/src/components/test/TestRunBar.tsx new file mode 100644 index 000000000..ed3902e6a --- /dev/null +++ b/apps/agent-forge/src/components/test/TestRunBar.tsx @@ -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 ( +
+ + {runStatus?.provider && {runStatus.provider}} + {elapsed !== undefined && ( + + {elapsed} + + )} + {traceId !== undefined && ( + + + )} + {actions !== undefined && {actions}} +
+ ); +} diff --git a/apps/agent-forge/src/components/test/__tests__/TestPane.test.tsx b/apps/agent-forge/src/components/test/__tests__/TestPane.test.tsx new file mode 100644 index 000000000..8f3654d65 --- /dev/null +++ b/apps/agent-forge/src/components/test/__tests__/TestPane.test.tsx @@ -0,0 +1,132 @@ +import { act } from 'react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render } from '../../../ui/__tests__/render'; +import type { AgentRunStatusPayload, ChatMessage } from '../../../../shared/wireTypes'; + +const state = vi.hoisted(() => ({ + runStatus: undefined as AgentRunStatusPayload | undefined, + messages: [] as ChatMessage[], +})); + +vi.mock('../../../state/AppState', () => ({ + useAppState: () => ({ + chat: { sessionId: 's1', messages: state.messages }, + chatSessions: [], + viewedChatSession: undefined, + runStatus: state.runStatus, + spans: [], + chatActionError: undefined, + startNewChat: vi.fn(), + viewChatSession: vi.fn(), + returnToLiveChat: vi.fn(), + sendChatMessage: vi.fn(), + }), +})); + +import { TestPane } from '../TestPane'; +import { useRunElapsed } from '../useRunElapsed'; + +const T0 = '2026-01-01T00:00:00.000Z'; + +function status(over: Partial): AgentRunStatusPayload { + return { agentId: 'agent', status: 'idle', updatedAt: T0, ...over }; +} + +/** jsdom has no scrolling; the thread scrolls itself to the newest message. */ +function stubScroll() { + Element.prototype.scrollTo = () => {}; +} + +describe('TestPane run bar', () => { + beforeEach(() => { + stubScroll(); + state.messages = []; + state.runStatus = undefined; + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('shows the status pill but no model, elapsed or trace link while idle', () => { + const container = render(); + const bar = container.querySelector('.test-run-bar')!; + expect(bar.getAttribute('role')).toBe('group'); + expect(bar.getAttribute('aria-label')).toBe('Test run'); + expect(bar.querySelector('.status-pill')).not.toBeNull(); + expect(bar.querySelector('.test-run-model')).toBeNull(); + expect(bar.querySelector('.test-run-elapsed')).toBeNull(); + expect(bar.querySelector('.run-trace-link')).toBeNull(); + }); + + it('shows the model and Open trace for a finished run, but no elapsed clock', () => { + state.runStatus = status({ status: 'done', provider: 'openai/gpt-4o', traceId: 'trace-9' }); + const container = render(); + const bar = container.querySelector('.test-run-bar')!; + expect(bar.querySelector('.test-run-model')?.textContent).toBe('openai/gpt-4o'); + const link = bar.querySelector('.run-trace-link')!; + expect(link.getAttribute('href')).toBe('#/traces/trace-9'); + expect(link.getAttribute('aria-label')).toBe('Open trace'); + expect(bar.querySelector('.test-run-elapsed')).toBeNull(); + }); + + it('hides Open trace until the status carries a traceId', () => { + state.runStatus = status({ status: 'running', provider: 'mock/mock-1' }); + const container = render(); + expect(container.querySelector('.test-run-bar .run-trace-link')).toBeNull(); + }); + + it('ticks the elapsed clock once a second while running, and stops when the run ends', () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-01-01T00:01:05.000Z')); + state.runStatus = status({ status: 'running', provider: 'mock/mock-1', updatedAt: T0 }); + const container = render(); + const elapsed = () => container.querySelector('.test-run-elapsed'); + expect(elapsed()?.textContent).toBe('1:05'); + expect(elapsed()?.getAttribute('aria-label')).toBe('Running for 1:05'); + + act(() => { + vi.advanceTimersByTime(2000); + }); + expect(elapsed()?.textContent).toBe('1:07'); + + state.runStatus = status({ status: 'done', provider: 'mock/mock-1', updatedAt: T0 }); + const idle = render(); + expect(idle.querySelector('.test-run-elapsed')).toBeNull(); + act(() => { + vi.advanceTimersByTime(5000); + }); + expect(idle.querySelector('.test-run-elapsed')).toBeNull(); + }); + + it('renders runActions at the right end of the run bar and aboveComposer above the composer', () => { + state.runStatus = status({ status: 'running', provider: 'mock/mock-1', updatedAt: T0 }); + const container = render( + Stop} aboveComposer={
steer
} />, + ); + const actions = container.querySelector('.test-run-actions')!; + expect(actions.parentElement?.classList.contains('test-run-bar')).toBe(true); + expect(actions.textContent).toBe('Stop'); + const slot = container.querySelector('.steer-slot')!; + expect(slot.previousElementSibling?.classList.contains('chat-messages')).toBe(true); + expect(slot.nextElementSibling?.classList.contains('chat-composer')).toBe(true); + }); +}); + +describe('useRunElapsed', () => { + function Probe({ runStatus, now }: { runStatus: AgentRunStatusPayload | undefined; now?: () => number }) { + const elapsed = useRunElapsed(runStatus, now); + return {elapsed ?? 'none'}; + } + + it('formats m:ss from updatedAt with an injected clock, and is undefined when not running', () => { + const now = () => Date.parse('2026-01-01T00:01:05.000Z'); + const running = render(); + expect(running.querySelector('.elapsed-probe')?.textContent).toBe('1:05'); + + for (const s of ['idle', 'done', 'paused', 'error'] as const) { + const container = render(); + expect(container.querySelector('.elapsed-probe')?.textContent).toBe('none'); + } + expect(render().querySelector('.elapsed-probe')?.textContent).toBe('none'); + }); +}); diff --git a/apps/agent-forge/src/components/test/test.css b/apps/agent-forge/src/components/test/test.css new file mode 100644 index 000000000..b9a448e2d --- /dev/null +++ b/apps/agent-forge/src/components/test/test.css @@ -0,0 +1,50 @@ +/* Test pane (components/test/): the run bar over the chat thread. */ + +.test-pane { + display: flex; + flex-direction: column; + height: 100%; + min-height: 0; +} +/* The chat panel owns its own scrolling; it takes whatever the run bar leaves. */ +.test-pane > .chat-panel { + flex: 1; + min-height: 0; +} + +/* A 32px strip: status pill, mono model, ticking elapsed, Open trace, actions. */ +.test-run-bar { + display: flex; + align-items: center; + gap: var(--space-2); + height: 32px; + padding: 0 var(--space-3); + border-bottom: 1px solid var(--border); + flex: none; + font-family: var(--font-ui); + font-size: 12px; + color: var(--text-muted); + overflow: hidden; +} +.test-run-model { + font-family: var(--font-mono); + font-size: 11.5px; + color: var(--text-muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.test-run-elapsed { + font-family: var(--font-mono); + font-size: 12px; + font-variant-numeric: tabular-nums; + color: var(--text-muted); +} +/* Slot content (T06's Stop) hugs the bar's right end. */ +.test-run-actions { + margin-left: auto; + display: inline-flex; + align-items: center; + gap: var(--space-2); + flex: none; +} diff --git a/apps/agent-forge/src/components/test/useOpenTrace.ts b/apps/agent-forge/src/components/test/useOpenTrace.ts new file mode 100644 index 000000000..6714d97fd --- /dev/null +++ b/apps/agent-forge/src/components/test/useOpenTrace.ts @@ -0,0 +1,19 @@ +import { useAppState } from '../../state/AppState'; +import { lastRunTraceId } from '../RunStatus'; +import { navigate } from '../../router/router'; + +/** + * The builder's "Open trace" action, shared by the Topbar's narrow More menu + * and available to the Test pane's run bar: the latest run's trace id - the + * status' own, else the live span feed's root (see `lastRunTraceId`) - and a + * handler that opens its `/traces/` route, the same destination the wide + * Topbar's trace link points at. + */ +export function useOpenTrace(): { traceId: string | undefined; openTrace: () => void } { + const { runStatus, spans } = useAppState(); + const traceId = lastRunTraceId(runStatus, spans); + const openTrace = () => { + if (traceId !== undefined) navigate(`/traces/${encodeURIComponent(traceId)}`); + }; + return { traceId, openTrace }; +} diff --git a/apps/agent-forge/src/components/test/useRunElapsed.ts b/apps/agent-forge/src/components/test/useRunElapsed.ts new file mode 100644 index 000000000..7d5358510 --- /dev/null +++ b/apps/agent-forge/src/components/test/useRunElapsed.ts @@ -0,0 +1,37 @@ +import { useEffect, useState } from 'react'; +import type { AgentRunStatusPayload } from '../../../shared/wireTypes'; + +/** `m:ss` - minutes unpadded, seconds always two digits (`1:05`, `0:59`, `12:00`). */ +function formatElapsed(totalSeconds: number): string { + const seconds = Math.max(0, Math.floor(totalSeconds)); + return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, '0')}`; +} + +/** + * How long the current run has been going, as `m:ss`, measured from the + * status' `updatedAt` (the server's last write for this run) - `undefined` + * while no run is in flight, so an idle bar shows no ticking clock. While + * running a one-second interval re-renders the bar; it is cleared on stop, + * on a new `updatedAt`, and on unmount. + * + * `now` is injectable for tests; production callers take `Date.now`. + */ +export function useRunElapsed( + runStatus: AgentRunStatusPayload | undefined, + now: () => number = Date.now, +): string | undefined { + const running = runStatus?.status === 'running'; + const startedAt = running ? runStatus?.updatedAt : undefined; + const [, setTick] = useState(0); + + useEffect(() => { + if (startedAt === undefined) return; + const id = setInterval(() => setTick((tick) => tick + 1), 1000); + return () => clearInterval(id); + }, [startedAt]); + + if (startedAt === undefined) return undefined; + const elapsedMs = now() - Date.parse(startedAt); + if (Number.isNaN(elapsedMs)) return undefined; + return formatElapsed(elapsedMs / 1000); +} diff --git a/apps/agent-forge/src/state/AppState.tsx b/apps/agent-forge/src/state/AppState.tsx index 831961e62..307b4a16e 100644 --- a/apps/agent-forge/src/state/AppState.tsx +++ b/apps/agent-forge/src/state/AppState.tsx @@ -24,7 +24,7 @@ import { useAgentStatuses, useAgentStream } from './useAgentStream'; import { useActiveProfile, useChatControls, useRunControls } from './useAgentControls'; type RailTab = 'agents' | 'nodes'; -export type DrawerTab = 'chat' | 'logs' | 'trace' | 'output' | 'history' | 'settings'; +export type DrawerTab = 'test' | 'logs' | 'trace' | 'output' | 'history' | 'settings'; interface AppState { store: AgentStore; @@ -152,9 +152,9 @@ interface AppState { startNewChat: () => Promise; /** A past session's full transcript, loaded for read-only browsing (undefined = viewing the live session). */ viewedChatSession: ChatSessionRecord | undefined; - /** Loads a past session for read-only viewing in the Chat tab. */ + /** Loads a past session for read-only viewing in the Test tab. */ viewChatSession: (sessionId: string) => Promise; - /** Switches the Chat tab back to the live session. */ + /** Switches the Test tab back to the live session. */ returnToLiveChat: () => void; /** @@ -174,7 +174,7 @@ export function AppStateProvider({ children }: { children: ReactNode }) { // Agents live in the server workspace (.lousho/agents/), not the browser. const store = useMemo(() => new HttpAgentStore(runtimeClient), []); const [railTab, setRailTab] = useState('agents'); - const [drawerTab, setDrawerTab] = useState('chat'); + const [drawerTab, setDrawerTab] = useState('test'); // A reload of an agents URL (`#/agents/:id`, `#/agents/:id/canvas`, `#/agents/:id/:tab`) reopens that agent. const [initialAgentId] = useState(() => agentRouteId(window.location.hash)); const doc = useAgentDocument(store, initialAgentId);