diff --git a/.changeset/execution-history-content-fit-table.md b/.changeset/execution-history-content-fit-table.md new file mode 100644 index 0000000000..7ff22521ae --- /dev/null +++ b/.changeset/execution-history-content-fit-table.md @@ -0,0 +1,13 @@ +--- +"@executor-js/plugin-execution-history": patch +--- + +Rebuild the runs list as a real semantic `` with content-fit columns. +Each column now sizes to the maximum width its rows need (browser +`table-layout: auto`), instead of fixed pixel widths — the sticky `` and +the rows share one column model so they stay aligned for free. Short / numeric / +badge columns (status, duration, tools, interaction, log) are centered; text +columns (started, trigger, actor, code) are left-aligned. The two columns that +can run long — `actor` and `code` — are width-capped and truncated (hover shows +the full value). Removes the fixed-width `COL_*` slot constants and the +`min-w` horizontal-scroll floor. diff --git a/packages/plugins/execution-history/src/react/RunsPage.tsx b/packages/plugins/execution-history/src/react/RunsPage.tsx index 931a278718..6c4173e0f7 100644 --- a/packages/plugins/execution-history/src/react/RunsPage.tsx +++ b/packages/plugins/execution-history/src/react/RunsPage.tsx @@ -10,7 +10,7 @@ import { LiveButton } from "./live-button"; import { LiveDivider } from "./live-row"; import { RefreshButton } from "./refresh-button"; import { RunListRow } from "./run-row"; -import { RunsShell } from "./shell"; +import { RunsShell, RUNS_TABLE_COLSPAN } from "./shell"; import { RunsTimelineChart } from "./timeline-chart"; import { emptyRunsFilters, @@ -165,15 +165,16 @@ export function RunsPage() { if (view.isLoadMoreError) { rowItems.push( -
-

Failed to load more

- -
, + + + , ); } @@ -226,9 +227,17 @@ export function RunsPage() { ) : undefined; const body = view.isLoading ? ( -
Loading runs…
+ + + ) : view.isError ? ( -
Unable to load runs.
+ + + ) : ( rowItems ); diff --git a/packages/plugins/execution-history/src/react/column-header.tsx b/packages/plugins/execution-history/src/react/column-header.tsx index 100631110d..1a2e9f47e9 100644 --- a/packages/plugins/execution-history/src/react/column-header.tsx +++ b/packages/plugins/execution-history/src/react/column-header.tsx @@ -4,38 +4,26 @@ import { cn } from "@executor-js/react/lib/utils"; import type { RunsSortField } from "./use-runs-list"; import type { RunColumns } from "./view"; -import { - COL_ACTOR, - COL_DURATION, - COL_INTERACTION, - COL_LOG, - COL_TOOLS, - COL_TRIGGER, -} from "./run-row"; // --------------------------------------------------------------------------- -// RunsColumnHeader — sticky header row aligned to RunListRow's layout. -// Imports the shared column slot classes from run-row so that header and rows -// use the same widths and breakpoints. +// RunsColumnHeader — the row. Cells are + {/* dot column (no label) */} + + + {/* Status (centered) */} + + + {/* Trigger (left) */} + {columns.trigger ? ( + + ) : null} + + {/* Actor (left) */} + {columns.actor ? ( + ) : null} - {/* Tools */} - {columns.tools ? tools : null} + {/* Duration (sortable, centered) */} + {columns.duration ? ( + + ) : null} - {/* Interaction */} - {columns.interaction ? interaction : null} + {/* Tools (centered) */} + {columns.tools ? ( + + ) : null} - {/* Log */} - {columns.log ? log : null} + {/* Interaction (centered) */} + {columns.interaction ? ( + + ) : null} + + {/* Log (centered) */} + {columns.log ? ( + + ) : null} - {/* Code */} - code - + {/* Code (left) */} + + ); } diff --git a/packages/plugins/execution-history/src/react/live-row.tsx b/packages/plugins/execution-history/src/react/live-row.tsx index c79d4c0b86..3bbd93da70 100644 --- a/packages/plugins/execution-history/src/react/live-row.tsx +++ b/packages/plugins/execution-history/src/react/live-row.tsx @@ -1,15 +1,23 @@ +import { RUNS_TABLE_COLSPAN } from "./shell"; + +// A full-width "Live" divider row between past and newly-arrived runs. Rendered +// as a . export function LiveDivider() { return ( -
-
- - - - - - Live - -
-
+
+ + ); } diff --git a/packages/plugins/execution-history/src/react/run-row.tsx b/packages/plugins/execution-history/src/react/run-row.tsx index 83df093a10..704398cf0a 100644 --- a/packages/plugins/execution-history/src/react/run-row.tsx +++ b/packages/plugins/execution-history/src/react/run-row.tsx @@ -1,4 +1,3 @@ -import { Button } from "@executor-js/react/components/button"; import { cn } from "@executor-js/react/lib/utils"; import { Badge } from "@executor-js/react/components/badge"; @@ -8,23 +7,13 @@ import { actorTone, STATUS_TONES, triggerTone } from "./status"; import { HoverCardTimestamp } from "./hover-card-timestamp"; import type { RunColumns } from "./view"; -// Column slot classes — RunListRow and RunsColumnHeader both apply these -// fixed-width slots (after the dot/started/status base columns) so the header -// and rows stay pixel-aligned. Every column is always rendered; when the -// viewport is narrower than the table's natural width the body scrolls -// horizontally (see RunsShell's min-width wrapper) instead of hiding columns. -// -// Trigger/duration/actor carry inline content (a dot+label, a sort button), so -// their display must be `flex` — composing `block` with an element's own -// `inline-flex` makes tailwind-merge collapse to one display value, which -// silently desyncs the header slot from the row slot (trigger) or wraps the -// sort label onto a second line (duration). A `flex` slot keeps both in lockstep. -export const COL_TRIGGER = "flex w-[120px] shrink-0"; -export const COL_ACTOR = "flex w-[170px] shrink-0"; -export const COL_DURATION = "flex w-[100px] shrink-0"; -export const COL_TOOLS = "block w-[80px] shrink-0"; -export const COL_INTERACTION = "block w-[100px] shrink-0"; -export const COL_LOG = "block w-[80px] shrink-0"; +// One semantic
+ + Failed to load more + + +
+ Loading runs… +
+ Unable to load runs. +
with no fixed widths; the +// table's content-fit layout sizes each column to its widest cell across the +// header and all rows. Alignment matches RunListRow (short/numeric center, text +// left). `bg-background` keeps rows from bleeding through the sticky header. // --------------------------------------------------------------------------- +const HEAD = + "border-b border-border/60 bg-background px-3 py-1.5 text-[10px] font-medium uppercase tracking-wider text-muted-foreground/60 whitespace-nowrap"; + interface SortButtonProps { readonly label: string; readonly field: RunsSortField; readonly sortField: RunsSortField; readonly sortDirection: "asc" | "desc"; readonly onSort: (field: RunsSortField) => void; - readonly className?: string; } -function SortButton({ - label, - field, - sortField, - sortDirection, - onSort, - className, -}: SortButtonProps) { +function SortButton({ label, field, sortField, sortDirection, onSort }: SortButtonProps) { const isActive = sortField === field; const Icon = isActive ? (sortDirection === "desc" ? ArrowDown : ArrowUp) : ArrowUpDown; @@ -46,11 +34,10 @@ function SortButton({ size="sm" onClick={() => onSort(field)} className={cn( - "h-auto p-0 inline-flex items-center gap-1 text-left", + "inline-flex h-auto items-center gap-1 p-0 text-left", "text-[10px] font-medium uppercase tracking-wider", - "text-muted-foreground/60 hover:text-foreground hover:bg-transparent", + "text-muted-foreground/60 hover:bg-transparent hover:text-foreground", isActive && "text-foreground", - className, )} > {label} @@ -73,52 +60,78 @@ export function RunsColumnHeader({ columns, }: RunsColumnHeaderProps) { return ( -
- {/* dot spacer */} - - - {/* Started (sortable) */} - - - {/* Status */} - status - - {/* Trigger */} - {columns.trigger ? trigger : null} - - {/* Actor */} - {columns.actor ? actor : null} - - {/* Duration (sortable) */} - {columns.duration ? ( +
+ + {/* Started (sortable, left) */} + + + status + + trigger + + actor + + + + tools + + interaction + + log + + code +
so it is valid inside the runs
+
+
+ + + + + + Live + +
+
+
row per run. Columns are content-fit — widths come from +// the browser's `table-layout: auto` over all rows + the header (see RunsShell), +// so there are no fixed column widths. Short / numeric / badge columns center; +// text columns left-align. The two columns that can run long — `actor` (a token +// id / email) and `code` — are width-capped and truncated (hover shows the full +// value) so content-fit can't blow the table out. +const CELL = "border-b border-border/40 px-3 py-2 align-middle whitespace-nowrap"; export interface RunListRowProps { readonly run: RunRow; @@ -39,106 +28,112 @@ export function RunListRow({ run, selected, isPast, columns, onSelect }: RunList const trigger = triggerTone(run.triggerKind); const actor = actorTone(run.actorKind); const isLive = run.status === "running" || run.status === "waiting_for_interaction"; - // Log error/warn counts are denormalized onto the slim row at write time, so - // the list never fetches/parses the full logs (now in the R2 detail object). const logErrors = run.logErrorCount; const logWarns = run.logWarnCount; return ( - - {/* Started */} - + {/* Started (left) */} + - {/* Status label */} - + {/* Status (centered) */} + - {/* Trigger (optional) */} + {/* Trigger (left, optional) */} {columns.trigger ? ( - - - {trigger.label} - + ) : null} - {/* Actor (optional) */} + {/* Actor (left, capped + truncated, optional) */} {columns.actor ? ( - + ) : null} - {/* Duration (optional) — slow runs (>5s) flagged. */} + {/* Duration (centered, optional) — slow runs (>5s) flagged */} {columns.duration ? ( - 5000 ? "text-destructive" : "text-muted-foreground", )} > {formatDuration(run.durationMs)} - + ) : null} - {/* Tool call count (optional) */} + {/* Tool call count (centered, optional) */} {columns.tools ? ( - 0 ? "text-foreground/80" : "text-muted-foreground/60", )} > {run.toolCallCount} - + ) : null} - {/* Interaction (optional) */} + {/* Interaction (centered, optional) */} {columns.interaction ? ( - + ) : null} - {/* Log error/warn counts (optional) */} + {/* Log error/warn counts (centered, optional) */} {columns.log ? ( - + ) : null} - {/* Code preview (always visible, fills remaining space). Pre-normalized at - write time; sliced here only to bound the rendered width. */} - + {/* Code preview (left, capped + truncated; hover reveals the full + write-bounded preview) */} + + ); } diff --git a/packages/plugins/execution-history/src/react/shell.tsx b/packages/plugins/execution-history/src/react/shell.tsx index 9118e55060..31300e7f47 100644 --- a/packages/plugins/execution-history/src/react/shell.tsx +++ b/packages/plugins/execution-history/src/react/shell.tsx @@ -7,12 +7,19 @@ import { cn } from "@executor-js/react/lib/utils"; // // The left
+ + + + {statusLabel(run.status)} - {isLive && ( - - {run.status === "waiting_for_interaction" ? " ·" : ""} - - )} - + {isLive && run.status === "waiting_for_interaction" ? ( + · + ) : null} + + + + {trigger.label} + + {run.actorId !== null ? ( - <> + {run.actorLabel ?? run.actorId} - + ) : ( — )} - + {run.hadInteraction ? ( — )} - + {logErrors === 0 && logWarns === 0 ? ( — ) : ( @@ -171,15 +166,18 @@ export function RunListRow({ run, selected, isPast, columns, onSelect }: RunList )} - + code: - "{run.codePreview.slice(0, 160)}" - - + "{run.codePreview}" +
with content-fit columns (table-layout: auto): the sticky +// and the rows share one column model, so columns size to their +// widest cell and stay aligned for free — no fixed widths. The scroller fires +// onLoadMore when the user scrolls within 320px of the bottom, guarded against +// re-firing while loading. // --------------------------------------------------------------------------- +// Upper bound on the visible column count (dot + started + status + up to six +// optional columns + code = 10). Full-width message rows span the whole table +// with this; an over-count colSpan is harmless (the browser clamps it). +export const RUNS_TABLE_COLSPAN = 12; + export interface RunsShellProps { readonly filterRail: React.ReactNode; readonly toolbar: React.ReactNode; @@ -77,41 +84,59 @@ export function RunsShell(props: RunsShellProps) { {chart != null &&
{chart}
} - {/* Table scroller — owns both axes. The inner min-width wrapper pins the - column grid: above it the code column flexes to fill, below it the - body scrolls horizontally instead of collapsing columns. The header - sticks to the top of this scroller and rides the same horizontal - scroll as the rows (top-only sticky), so the two stay aligned. */} + {/* Table scroller — owns both axes. The sticky sticks to the top + of this scroller (below the toolbar/chart) and rides the same + horizontal scroll as the rows. `border-separate border-spacing-0` + keeps cell borders rendering on the sticky header (a known + border-collapse + sticky browser bug). No table width is forced, so + columns content-fit: narrower than the pane → left-aligned with slack + on the right; wider → horizontal scroll. */}
-
-
- {columnHeader} -
- - {isEmpty ? ( -
- {emptyState ?? ( -

- No runs match the current filters. -

- )} -
- ) : ( - <> - {children} - {isLoadingMore && ( -
- Loading more… -
- )} - {!hasMore && !isLoadingMore && ( -
- End of history -
- )} - - )} -
+ {/* oxlint-disable-next-line react/forbid-elements -- the runs table needs + this custom scroll container (the onScroll infinite-scroll handler) + + a sticky thead inside it; the shared
wraps its own + overflow-x-auto div, which would conflict. Same suppression the + shared Table component itself uses on its raw
. */} +
+ {columnHeader} + + {isEmpty ? ( + + + + ) : ( + <> + {children} + {isLoadingMore && ( + + + + )} + {!hasMore && !isLoadingMore && ( + + + + )} + + )} + +
+ {emptyState ?? ( + + No runs match the current filters. + + )} +
+ Loading more… +
+ End of history +