Skip to content
Closed
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
33 changes: 32 additions & 1 deletion apps/agent-forge/e2e/docShots.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,8 @@ async function shoot(page: Page, screen: string) {
await page.screenshot({ path: path.join(OUT, `studio-${screen}.png`) });
}

const rows = (page: Page) => page.getByRole('main').locator('table.data-table tbody tr');
/** Data rows only: a first load shows skeleton rows (`.skeleton-row`) until the rows arrive. */
const rows = (page: Page) => page.getByRole('main').locator('table.data-table tbody tr:not(.skeleton-row)');

test('agents: the builder with an agent', async ({ page }) => {
await open(page, '/agents');
Expand Down Expand Up @@ -208,6 +209,8 @@ test('monitoring: fleet and charts', async ({ page }) => {

test('scores: an expanded eval', async ({ page }) => {
await open(page, '/evals/scores?open=e2e-eval');
// The evals table must show its rows, not its first-load skeleton.
await expect(rows(page).filter({ hasText: 'e2e-eval' }).first()).toBeVisible();
await expect(page.getByRole('region', { name: 'Eval e2e-eval' })).toBeVisible();
await shoot(page, 'scores');
});
Expand Down Expand Up @@ -254,3 +257,31 @@ test('command palette: open over Traces', async ({ page }) => {
await page.waitForTimeout(150);
await page.screenshot({ path: path.join(OUT, 'studio-command-palette.png') });
});

// --- Kit states (x- shots: review-only, never used in the docs) -------------

test('x-kit: traces empty state', async ({ page }) => {
await page.route('**/observe/api/v1/traces?**', (route) => json(route, { traces: [], total: 0, limit: 50, offset: 0 }));
await open(page, '/traces');
await expect(page.getByRole('main').locator('.empty-state')).toBeVisible();
await shoot(page, 'x-kit-empty-traces');
});

test('x-kit: traces table loading', async ({ page }) => {
// Never answered: the table stays in its first-load state.
await page.route('**/observe/api/v1/traces?**', () => undefined);
await open(page, '/traces');
await expect(page.getByRole('main').locator('table.data-table[aria-busy="true"]')).toBeVisible();
await shoot(page, 'x-kit-loading-traces');
});

test('x-kit: filter bar with a facet and the custom range open', async ({ page }) => {
await open(page, '/traces');
await expect(rows(page).first()).toBeVisible();
const main = page.getByRole('main');
await main.getByRole('button', { name: 'Custom' }).click();
await main.locator('.facet-btn').first().click();
await expect(main.locator('.facet-pop')).toBeVisible();
await page.waitForTimeout(300);
await page.screenshot({ path: path.join(OUT, 'studio-x-kit-filters.png') });
});
38 changes: 37 additions & 1 deletion apps/agent-forge/e2e/revamp.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,8 @@ function counter() {
}

const primaryNav = (page: Page) => page.getByRole('navigation', { name: 'Primary' });
const bodyRows = (page: Page) => page.getByRole('main').locator('table.data-table tbody tr');
/** Data rows only: a first load shows skeleton rows (`.skeleton-row`) until the rows arrive. */
const bodyRows = (page: Page) => page.getByRole('main').locator('table.data-table tbody tr:not(.skeleton-row)');

async function openApp(page: Page, hash = '') {
await page.goto(`${APP}${hash ? `#${hash}` : ''}`);
Expand Down Expand Up @@ -329,3 +330,38 @@ test('queue review at 1280px: a trace tree row name never wraps one letter per l
expect(box.height, `${await name.textContent()} wraps`).toBeLessThanOrEqual(line * 2 + 1);
}
});

// The traces columns add up to ~1330px: at 1280 the table is wider than the screen, so its wrap scrolls
// sideways (and the header cannot stick to the screen); at 1920 it fits (every column shown), the wrap only clips, and it sticks.
test('traces at 1280px: a table wider than the screen scrolls sideways inside its wrap', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await page.goto(`${APP}#/traces`);
await expect(bodyRows(page).first()).toBeVisible();
const wrap = page.getByRole('main').locator('.data-table-wrap');
await expect(wrap).toHaveClass(/dt-scroll-x/);
expect(await wrap.evaluate((w) => w.scrollWidth > w.clientWidth && getComputedStyle(w).overflowX === 'auto')).toBe(true);
});

test('traces at 1920px: the table header stays in view while the screen scrolls', async ({ page }) => {
await page.setViewportSize({ width: 1920, height: 800 });
await page.goto(`${APP}#/traces`);
await expect(bodyRows(page).nth(30)).toBeVisible();
await expect(page.getByRole('main').locator('.data-table-wrap')).not.toHaveClass(/dt-scroll-x/);
const header = page.getByRole('main').locator('table.data-table thead th').first();
const before = (await header.boundingBox())!;
// Scroll the screen's own scroller (the nearest scrollable ancestor of the table) by 600px.
const scrolled = await header.evaluate((th) => {
let el: HTMLElement | null = th.parentElement;
while (el && !(el.scrollHeight > el.clientHeight && /auto|scroll/.test(getComputedStyle(el).overflowY))) el = el.parentElement;
if (!el) return 0;
el.scrollTop += 600;
return el.scrollTop;
});
expect(scrolled, 'the screen scrolls').toBeGreaterThan(300);
await expect.poll(async () => (await header.boundingBox())!.y).toBeLessThan(before.y + 1);
const after = (await header.boundingBox())!;
expect(after.y, 'header still in the viewport').toBeGreaterThanOrEqual(0);
expect(after.y + after.height).toBeLessThanOrEqual(800);
// ...and it is the sticky header that kept it there, not a page that did not move.
await expect(bodyRows(page).first()).not.toBeInViewport();
});
2 changes: 2 additions & 0 deletions apps/agent-forge/src/__tests__/contrast.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,8 @@ const PAIRS: [text: string, background: string][] = [
['on-accent', 'accent-solid-hover'],
// A switched-on chip (Requires approval) draws --accent on the accent tint.
['accent', 'accent-soft'],
// MetricCard: a good delta draws --success on the card's --surface (a bad one --danger, listed above).
['success', 'surface'],
];

describe('theme contrast', () => {
Expand Down
2 changes: 2 additions & 0 deletions apps/agent-forge/src/screens/traces/TracesScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
*/
import './traces.css';
import { useEffect, useMemo } from 'react';
import { SearchX } from 'lucide-react';
import { navigate } from '../../router/router';
import { useRoute } from '../../router/useRoute';
import { useAgents, useDaemonDown, useDaemonHealth, useTraces } from '../../observe/useObserve';
Expand Down Expand Up @@ -159,6 +160,7 @@ function TracesList() {
loading={traces.loading}
empty={
<EmptyState
icon={SearchX}
title="No traces match these filters"
hint="Traces appear once an agent with LOUSHO_OBSERVE=1 finishes a run. Try a wider time range."
/>
Expand Down
44 changes: 44 additions & 0 deletions apps/agent-forge/src/ui/Card.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { useId, type ReactNode } from 'react';
import './card.css';

/** Props for {@link Card}. */
interface CardProps {
/** Header title (an h2). With `as='section'` it also labels the card. */
title?: ReactNode;
/** Muted line under the title. */
description?: ReactNode;
/** Right side of the header row (buttons, a range picker). */
actions?: ReactNode;
/** `'md'` (default) pads the body; `'none'` lets a DataTable run edge to edge. */
padding?: 'md' | 'none';
className?: string;
/** `'section'` (default) is a region labelled by its title; `'div'` a plain box. */
as?: 'section' | 'div';
children?: ReactNode;
}

/**
* The Studio's panel: surface, hairline border, large radius, a soft lift,
* and an optional header row (title + description left, actions right).
*/
export function Card({ title, description, actions, padding = 'md', className, as: Tag = 'section', children }: CardProps) {
const titleId = useId();
const labelled = Tag === 'section' && title != null;
const classes = ['card', `card-pad-${padding}`, className].filter(Boolean).join(' ');
return (
<Tag className={classes} aria-labelledby={labelled ? titleId : undefined}>
{title != null && (
<div className="card-header">
<div className="card-heading">
<h2 className="card-title" id={titleId}>
{title}
</h2>
{description != null && <p className="card-description">{description}</p>}
</div>
{actions != null && <div className="card-actions">{actions}</div>}
</div>
)}
<div className="card-body">{children}</div>
</Tag>
);
}
121 changes: 99 additions & 22 deletions apps/agent-forge/src/ui/DataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,9 @@ import {
type ReactNode,
type RefObject,
} from 'react';
import { AlertCircle, ArrowDown, ArrowUp, ArrowUpDown, ChevronLeft, ChevronRight } from 'lucide-react';
import { isEditableTarget } from '../canvas/isEditableTarget';
import { SkeletonRows } from './Skeleton';
import './dataTable.css';

/** Default minimum column width (px) when a column sets no `minWidth`. */
Expand Down Expand Up @@ -37,6 +39,9 @@ export interface Column<T> {
hideBelow?: number;
}

/** Skeleton rows shown while a first load is in flight. */
const SKELETON_ROWS = 6;

/** The active sort: one column key and a direction. The table never sorts rows itself. */
export interface TableSort {
key: string;
Expand All @@ -59,6 +64,12 @@ interface DataTableProps<T> {
density?: 'compact' | 'comfortable';
loading?: boolean;
empty: ReactNode;
/**
* Opt-in bounded scroller: the wrap scrolls both ways inside this height (px
* number or CSS length) and the header sticks inside it. Without it the
* header sticks to the screen's scroller while the table fits its width.
*/
maxHeight?: number | string;
}

type Widths = Record<string, number>;
Expand Down Expand Up @@ -122,6 +133,29 @@ function useContainerWidth(ref: RefObject<HTMLElement>): number {
return width;
}

/**
* True while the table is wider than its wrap (resized columns, min-widths
* past the container). Only then does the wrap scroll sideways; otherwise it
* clips, so it is no scroll container and the sticky header sticks to the
* screen's own scroller.
*/
function useOverflowsX(wrapRef: RefObject<HTMLElement>, tableRef: RefObject<HTMLElement>): boolean {
const [overflows, setOverflows] = useState(false);
useLayoutEffect(() => {
const wrap = wrapRef.current;
const table = tableRef.current;
if (!wrap || !table) return;
const next = table.offsetWidth > wrap.clientWidth + 1;
if (next !== overflows) setOverflows(next);
});
return overflows;
}

function wrapClass(scrollsX: boolean, maxHeight: number | string | undefined): string {
if (maxHeight !== undefined) return 'data-table-wrap dt-scroll';
return scrollsX ? 'data-table-wrap dt-scroll-x' : 'data-table-wrap';
}

/** Column widths keyed by column key, remembered per table id. */
function useColumnWidths(id: string | undefined) {
const [widths, setWidths] = useState<Widths>(() => loadWidths(id));
Expand Down Expand Up @@ -234,23 +268,23 @@ interface HeaderCellProps<T> {
function HeaderCell<T>({ column, width, sort, onSortChange, onResize, onCommit }: HeaderCellProps<T>) {
const sortable = Boolean(column.sortable);
const state = ariaSort(sort, column.key);
const arrow = state === 'ascending' ? '▲' : state === 'descending' ? '▼' : '';
const Arrow = state === 'ascending' ? ArrowUp : state === 'descending' ? ArrowDown : ArrowUpDown;
return (
<th
scope="col"
aria-sort={sortable ? state : undefined}
className={column.align === 'right' ? 'dt-right' : undefined}
className={alignClass(column)}
style={{ minWidth: minWidthOf(column) }}
>
{sortable ? (
<button type="button" className="dt-sort" onClick={() => onSortChange?.(nextSort(sort, column.key))}>
{column.header}
<span className="dt-sort-arrow" aria-hidden="true">
{arrow}
</span>
<span className="dt-label">{column.header}</span>
<Arrow className={state === 'none' ? 'dt-sort-arrow dt-sort-idle' : 'dt-sort-arrow'} size={14} strokeWidth={1.75} aria-hidden="true" />
</button>
) : (
<span className="dt-head">{column.header}</span>
<span className="dt-head">
<span className="dt-label">{column.header}</span>
</span>
)}
<ResizeHandle label={columnLabel(column)} explicit={width} min={minWidthOf(column)} onResize={onResize} onCommit={onCommit} />
</th>
Expand All @@ -263,6 +297,29 @@ function cellContent<T>(column: Column<T>, row: T): ReactNode {
return value == null ? '' : String(value);
}

/** Plain-text cell content doubles as its tooltip, so ellipsised text stays readable. */
const titleOf = (content: ReactNode) =>
(typeof content === 'string' && content !== '') || typeof content === 'number' ? String(content) : undefined;

const alignClass = <T,>(c: Column<T> | undefined) => (c?.align === 'right' ? 'dt-right' : undefined);

/** Props for {@link ErrorCell}. */
interface ErrorCellProps {
children: ReactNode;
/** Tooltip; defaults to `children` when that is a string. */
title?: string;
}

/** An error message in a table cell: danger text behind an alert icon, truncated with a tooltip. */
export function ErrorCell({ children, title }: ErrorCellProps) {
return (
<span className="dt-error" title={title ?? (typeof children === 'string' ? children : undefined)}>
<AlertCircle size={14} strokeWidth={1.75} aria-hidden="true" />
<span className="dt-error-text">{children}</span>
</span>
);
}

interface BodyRowProps<T> {
row: T;
index: number;
Expand All @@ -283,11 +340,14 @@ function BodyRow<T>({ row, index, columns, active, selected, onOpen }: BodyRowPr
aria-current={selected ? true : undefined}
onClick={openable ? (e) => !(e.target as Element).closest('a, button, input, select, textarea') && onOpen!(index) : undefined}
>
{columns.map((c) => (
<td key={c.key} className={c.align === 'right' ? 'dt-right' : undefined}>
{cellContent(c, row)}
</td>
))}
{columns.map((c) => {
const content = cellContent(c, row);
return (
<td key={c.key} className={alignClass(c)} title={titleOf(content)}>
{content}
</td>
);
})}
</tr>
);
}
Expand Down Expand Up @@ -331,6 +391,20 @@ function useRowNavigation<T>(rows: T[], rowKey: (r: T) => string, onRowOpen: ((r
return { tableRef, current, onKeyDown, open };
}

/** The body with no rows: skeleton rows on a first load, else `empty` in one full-width cell. */
function EmptyBody<T>({ columns, loading, empty }: { columns: Column<T>[]; loading?: boolean; empty: ReactNode }) {
if (loading) {
return <SkeletonRows as="tr" rows={SKELETON_ROWS} cols={columns.length} cellClassName={(i) => alignClass(columns[i])} />;
}
return (
<tr>
<td className="dt-empty" colSpan={columns.length}>
{empty}
</td>
</tr>
);
}

function Pager({ offset, limit, total, onChange }: NonNullable<DataTableProps<unknown>['page']>) {
const fmt = (n: number) => n.toLocaleString('en-US');
// An offset past the end (total shrank under us) still reads as a valid range.
Expand All @@ -340,11 +414,13 @@ function Pager({ offset, limit, total, onChange }: NonNullable<DataTableProps<un
return (
<div className="dt-pager">
<span className="dt-pager-range">{range}</span>
<button type="button" disabled={offset <= 0} onClick={() => onChange(Math.max(0, offset - limit))}>
<button type="button" className="btn btn-sm" disabled={offset <= 0} onClick={() => onChange(Math.max(0, offset - limit))}>
<ChevronLeft aria-hidden="true" />
Previous
</button>
<button type="button" disabled={offset + limit >= total} onClick={() => onChange(offset + limit)}>
<button type="button" className="btn btn-sm" disabled={offset + limit >= total} onClick={() => onChange(offset + limit)}>
Next
<ChevronRight aria-hidden="true" />
</button>
</div>
);
Expand All @@ -354,17 +430,22 @@ function Pager({ offset, limit, total, onChange }: NonNullable<DataTableProps<un
* The Studio's shared table: semantic `<table>`, controlled sort (server-side,
* rows are rendered in the order given), keyboard row navigation, resizable
* columns remembered per `id`, responsive `hideBelow` columns, and a pager.
* The header is sticky against the screen's scroller; the wrap becomes a
* horizontal scroller (`.dt-scroll-x`, header no longer sticky) only while the
* table is wider than its container. Row action buttons go in an element with
* class `dt-actions` to get the compact in-row button size.
*/
export function DataTable<T>(props: DataTableProps<T>) {
const { id, columns, rows, rowKey, sort, onSortChange, onRowOpen, selectedKey, page, density = 'compact', loading, empty } = props;
const { id, columns, rows, rowKey, sort, onSortChange, onRowOpen, selectedKey, page, density = 'compact', loading, empty, maxHeight } = props;
const wrapRef = useRef<HTMLDivElement>(null);
const containerWidth = useContainerWidth(wrapRef);
const { widths, setWidth, persist } = useColumnWidths(id);
const { tableRef, current, onKeyDown, open } = useRowNavigation(rows, rowKey, onRowOpen);
const scrollsX = useOverflowsX(wrapRef, tableRef);
const visible = columns.filter((c) => c.hideBelow === undefined || containerWidth >= c.hideBelow);
const widthOf = (c: Column<T>) => widths[c.key] ?? c.width;
return (
<div className="data-table-wrap" ref={wrapRef}>
<div className={wrapClass(scrollsX, maxHeight)} style={maxHeight === undefined ? undefined : { maxHeight }} ref={wrapRef}>
<table
ref={tableRef}
className={`data-table dt-${density}`}
Expand Down Expand Up @@ -393,12 +474,8 @@ export function DataTable<T>(props: DataTableProps<T>) {
</tr>
</thead>
<tbody>
{rows.length === 0 && !loading ? (
<tr>
<td className="dt-empty" colSpan={visible.length}>
{empty}
</td>
</tr>
{rows.length === 0 ? (
<EmptyBody columns={visible} loading={loading} empty={empty} />
) : (
rows.map((r, i) => (
<BodyRow
Expand Down
Loading
Loading