From a07b5d2287284823c1a5f4c6a96f40a310c5bcb8 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Mon, 15 Jun 2026 02:12:51 +0530 Subject: [PATCH 1/2] feat(execution-history): restore parity gaps from the old runs UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the user-facing affordances the plugin dropped when it replaced the old observability runs UI — all client-side, no API/store changes. - HoverCardTimestamp: hovering a timestamp (list-row started cell + drawer Started/Completed) reveals epoch / UTC / local-tz / relative renderings, each click-to-copy. Native Date/Intl; reuses the shared Radix HoverCard. - Log column: optional list column counting [error]/[warn] lines from the row's logsJson (off by default, toggle in view options). - Slow-run highlight: durations over 5s render in the destructive color. - Code preview widened 80 -> 160 chars. - Live/Refresh buttons gain title hints; "Last 15m" time-range preset. - Toolbar row-count ("N of M"); empty-state hint line; end-of-history footer. - Timeline chart: seconds-resolution axis + tooltip for sub-minute buckets; tooltip escapes the view box and ignores pointer events. - Column visibility persists across reloads via localStorage. Filter-state persistence and the tool:/code:/duration_ms: filters are deferred — they need a filter schema and API/store work respectively. --- .../execution-history/src/react/RunsPage.tsx | 34 +++++- .../src/react/column-header.tsx | 12 +- .../src/react/detail-drawer.tsx | 13 ++- .../src/react/filter-rail.tsx | 1 + .../src/react/hover-card-timestamp.tsx | 104 ++++++++++++++++++ .../src/react/live-button.tsx | 1 + .../src/react/refresh-button.tsx | 1 + .../execution-history/src/react/run-row.tsx | 53 +++++++-- .../execution-history/src/react/shell.tsx | 5 + .../src/react/timeline-chart.tsx | 21 ++-- .../src/react/view-options.tsx | 1 + .../execution-history/src/react/view.ts | 3 + 12 files changed, 229 insertions(+), 20 deletions(-) create mode 100644 packages/plugins/execution-history/src/react/hover-card-timestamp.tsx diff --git a/packages/plugins/execution-history/src/react/RunsPage.tsx b/packages/plugins/execution-history/src/react/RunsPage.tsx index 90d5d80d0..12f81cb9f 100644 --- a/packages/plugins/execution-history/src/react/RunsPage.tsx +++ b/packages/plugins/execution-history/src/react/RunsPage.tsx @@ -36,11 +36,29 @@ interface ShortcutContext { readonly selected: string | null; } +const COLUMNS_STORAGE_KEY = "executionHistory.columns"; + +// Column visibility persists across reloads as a comma-joined list of the +// visible keys (avoids JSON.parse, which the repo lints against). Keys absent +// from the stored list — including columns added in a later release — read as +// hidden, falling back to DEFAULT_COLUMNS only when nothing is stored. +const readStoredColumns = (): RunColumns => { + if (typeof window === "undefined") return DEFAULT_COLUMNS; + const raw = window.localStorage.getItem(COLUMNS_STORAGE_KEY); + if (raw == null) return DEFAULT_COLUMNS; + const visible = new Set(raw.split(",").filter(Boolean)); + const next = { ...DEFAULT_COLUMNS }; + for (const key of Object.keys(next) as RunColumnKey[]) { + next[key] = visible.has(key); + } + return next; +}; + export function RunsPage() { const [filters, setFilters] = useState(emptyRunsFilters); const [live, setLive] = useState(false); const [selected, setSelected] = useState(null); - const [columns, setColumns] = useState(DEFAULT_COLUMNS); + const [columns, setColumns] = useState(readStoredColumns); const [commandOpen, setCommandOpen] = useState(false); const [helpOpen, setHelpOpen] = useState(false); const [railCollapsed, setRailCollapsed] = useState(false); @@ -60,6 +78,12 @@ export function RunsPage() { setColumns((current) => ({ ...current, [key]: !current[key] })); }, []); + useEffect(() => { + if (typeof window === "undefined") return; + const visible = (Object.keys(columns) as RunColumnKey[]).filter((key) => columns[key]); + window.localStorage.setItem(COLUMNS_STORAGE_KEY, visible.join(",")); + }, [columns]); + // Keep the latest view/selected reachable from a stable keydown listener. const shortcutRef = useRef({ view, selected }); shortcutRef.current = { view, selected }; @@ -153,6 +177,11 @@ export function RunsPage() {

Runs

+ {view.meta != null && ( + + {view.rows.length} of {view.meta.filterRowCount} + + )}
} railCollapsed={railCollapsed} diff --git a/packages/plugins/execution-history/src/react/column-header.tsx b/packages/plugins/execution-history/src/react/column-header.tsx index b69bdeae1..644e73cb4 100644 --- a/packages/plugins/execution-history/src/react/column-header.tsx +++ b/packages/plugins/execution-history/src/react/column-header.tsx @@ -4,7 +4,14 @@ 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_TOOLS, COL_TRIGGER } from "./run-row"; +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. @@ -107,6 +114,9 @@ export function RunsColumnHeader({ {/* Interaction */} {columns.interaction ? interaction : null} + {/* Log */} + {columns.log ? log : null} + {/* Code */} code
diff --git a/packages/plugins/execution-history/src/react/detail-drawer.tsx b/packages/plugins/execution-history/src/react/detail-drawer.tsx index 48e37f1d3..73d403427 100644 --- a/packages/plugins/execution-history/src/react/detail-drawer.tsx +++ b/packages/plugins/execution-history/src/react/detail-drawer.tsx @@ -20,6 +20,7 @@ import { cn } from "@executor-js/react/lib/utils"; import type { InteractionRow, InteractionStatus, RunRow, ToolCallRow } from "../sdk/collections"; import { runDetailAtom, runToolCallsAtom } from "./atoms"; import { formatDateTime, formatDuration, logLines, prettyJson, statusLabel } from "./format"; +import { HoverCardTimestamp } from "./hover-card-timestamp"; import { STATUS_TONES, triggerTone } from "./status"; // --------------------------------------------------------------------------- @@ -340,8 +341,16 @@ function DetailContent(props: { )} - {formatDateTime(run.startedAt)} - {formatDateTime(run.completedAt)} + + + + + {run.completedAt != null ? ( + + ) : ( + formatDateTime(run.completedAt) + )} +
diff --git a/packages/plugins/execution-history/src/react/filter-rail.tsx b/packages/plugins/execution-history/src/react/filter-rail.tsx index 9a15b0744..41e46d673 100644 --- a/packages/plugins/execution-history/src/react/filter-rail.tsx +++ b/packages/plugins/execution-history/src/react/filter-rail.tsx @@ -30,6 +30,7 @@ export interface TimeRangePreset { } export const TIME_RANGE_PRESETS: readonly TimeRangePreset[] = [ + { key: "15m", label: "Last 15m", ms: 15 * 60 * 1000 }, { key: "1h", label: "Last 1h", ms: 60 * 60 * 1000 }, { key: "24h", label: "Last 24h", ms: 24 * 60 * 60 * 1000 }, { key: "7d", label: "Last 7d", ms: 7 * 24 * 60 * 60 * 1000 }, diff --git a/packages/plugins/execution-history/src/react/hover-card-timestamp.tsx b/packages/plugins/execution-history/src/react/hover-card-timestamp.tsx new file mode 100644 index 000000000..2fe8535c9 --- /dev/null +++ b/packages/plugins/execution-history/src/react/hover-card-timestamp.tsx @@ -0,0 +1,104 @@ +import { useCallback, useState } from "react"; +import { Check, Copy } from "lucide-react"; +import { + HoverCard, + HoverCardContent, + HoverCardTrigger, +} from "@executor-js/react/components/hover-card"; +import { cn } from "@executor-js/react/lib/utils"; + +import { formatRelative } from "./format"; + +// --------------------------------------------------------------------------- +// Hover popup over a timestamp: the trigger shows whatever `display` is given +// (the list row passes a relative "1 hour ago"; the drawer falls back to the +// absolute format), and hovering reveals the epoch ms, UTC, local-tz, and +// relative renderings — each a click-to-copy row. Native Date/Intl only (the +// plugin deliberately carries no date-fns dep); reuses the shared Radix +// HoverCard from @executor-js/react. +// --------------------------------------------------------------------------- + +const absolute = (timestamp: number, timeZone?: string): string => + new Intl.DateTimeFormat(undefined, { + year: "numeric", + month: "short", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + hour12: false, + ...(timeZone ? { timeZone } : {}), + }).format(timestamp); + +const LOCAL_TZ = Intl.DateTimeFormat().resolvedOptions().timeZone; + +export interface HoverCardTimestampProps { + readonly timestamp: number; + /** Trigger text; defaults to the absolute local format. */ + readonly display?: React.ReactNode; + readonly side?: "top" | "right" | "bottom" | "left"; + readonly align?: "start" | "center" | "end"; + readonly className?: string; +} + +export function HoverCardTimestamp({ + timestamp, + display, + side = "right", + align = "start", + className, +}: HoverCardTimestampProps) { + return ( + + + + {display ?? absolute(timestamp)} + + + +
+ + + + +
+
+
+ ); +} + +function CopyRow({ label, value }: { readonly label: string; readonly value: string }) { + const [copied, setCopied] = useState(false); + const copy = useCallback( + (event: React.MouseEvent | React.KeyboardEvent) => { + event.stopPropagation(); + void navigator.clipboard.writeText(value).then(() => { + setCopied(true); + window.setTimeout(() => setCopied(false), 1500); + }); + }, + [value], + ); + return ( +
{ + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + copy(event); + } + }} + className="group flex items-center justify-between gap-4 text-xs" + > +
{label}
+
+ + {copied ? : } + + {value} +
+
+ ); +} diff --git a/packages/plugins/execution-history/src/react/live-button.tsx b/packages/plugins/execution-history/src/react/live-button.tsx index 6c82a6849..d9f802204 100644 --- a/packages/plugins/execution-history/src/react/live-button.tsx +++ b/packages/plugins/execution-history/src/react/live-button.tsx @@ -9,6 +9,7 @@ export function LiveButton(props: { readonly active: boolean; readonly onToggle: variant="outline" size="sm" aria-pressed={props.active} + title={props.active ? "Pause live refresh (j)" : "Start live refresh (j)"} onClick={props.onToggle} className={cn( "gap-1.5", diff --git a/packages/plugins/execution-history/src/react/refresh-button.tsx b/packages/plugins/execution-history/src/react/refresh-button.tsx index ef3d0d271..d7e778e2c 100644 --- a/packages/plugins/execution-history/src/react/refresh-button.tsx +++ b/packages/plugins/execution-history/src/react/refresh-button.tsx @@ -11,6 +11,7 @@ export function RefreshButton(props: { variant="ghost" size="icon-sm" aria-label="Refresh" + title="Refresh (r)" onClick={props.onClick} disabled={props.isLoading} > diff --git a/packages/plugins/execution-history/src/react/run-row.tsx b/packages/plugins/execution-history/src/react/run-row.tsx index 290ef27ff..dbb753402 100644 --- a/packages/plugins/execution-history/src/react/run-row.tsx +++ b/packages/plugins/execution-history/src/react/run-row.tsx @@ -3,8 +3,9 @@ import { cn } from "@executor-js/react/lib/utils"; import { Badge } from "@executor-js/react/components/badge"; import type { RunRow } from "../sdk/collections"; -import { formatDateTime, formatDuration, formatRelative, statusLabel } from "./format"; +import { formatDuration, formatRelative, logLines, statusLabel } from "./format"; 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 @@ -24,6 +25,8 @@ export const COL_ACTOR = "hidden w-[170px] shrink-0 lg:flex"; export const COL_DURATION = "hidden w-[100px] shrink-0 md:flex"; export const COL_TOOLS = "hidden xl:block w-[80px] shrink-0"; export const COL_INTERACTION = "hidden xl:block w-[100px] shrink-0"; +// Log error/warn counts — least critical, so only on the widest screens. +export const COL_LOG = "hidden 2xl:block w-[80px] shrink-0"; export interface RunListRowProps { readonly run: RunRow; @@ -38,6 +41,9 @@ 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"; + const logs = columns.log ? logLines(run.logsJson) : []; + const logErrors = logs.filter((line) => line.toLowerCase().includes("[error]")).length; + const logWarns = logs.filter((line) => line.toLowerCase().includes("[warn]")).length; return ( diff --git a/packages/plugins/execution-history/src/react/shell.tsx b/packages/plugins/execution-history/src/react/shell.tsx index cf4b0b758..b4eb9c0ee 100644 --- a/packages/plugins/execution-history/src/react/shell.tsx +++ b/packages/plugins/execution-history/src/react/shell.tsx @@ -125,6 +125,11 @@ export function RunsShell(props: RunsShellProps) { Loading more…
)} + {!hasMore && !isLoadingMore && ( +
+ End of history +
+ )} )} diff --git a/packages/plugins/execution-history/src/react/timeline-chart.tsx b/packages/plugins/execution-history/src/react/timeline-chart.tsx index 7b8983707..d221cc049 100644 --- a/packages/plugins/execution-history/src/react/timeline-chart.tsx +++ b/packages/plugins/execution-history/src/react/timeline-chart.tsx @@ -51,15 +51,17 @@ const buildAxisFormatter = const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; const options: Intl.DateTimeFormatOptions = - bucketMs < DAY_MS - ? { hour: "2-digit", minute: "2-digit", hour12: false } - : bucketMs >= 7 * DAY_MS - ? { month: "short", day: "numeric" } - : { month: "2-digit", day: "2-digit" }; + bucketMs <= 60_000 + ? { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: false } + : bucketMs < DAY_MS + ? { hour: "2-digit", minute: "2-digit", hour12: false } + : bucketMs >= 7 * DAY_MS + ? { month: "short", day: "numeric" } + : { month: "2-digit", day: "2-digit" }; return new Intl.DateTimeFormat(undefined, options).format(date); }; -const formatTooltipLabel = (value: number): string => { +const formatTooltipLabel = (value: number, bucketMs: number): string => { const date = new Date(value); if (Number.isNaN(date.getTime())) return "—"; return new Intl.DateTimeFormat(undefined, { @@ -67,6 +69,7 @@ const formatTooltipLabel = (value: number): string => { day: "numeric", hour: "2-digit", minute: "2-digit", + ...(bucketMs <= 60_000 ? { second: "2-digit" as const } : {}), hour12: false, }).format(date); }; @@ -163,11 +166,15 @@ export function RunsTimelineChart(props: { /> { const first = payload?.[0]?.payload as { bucketStart?: number } | undefined; - return first?.bucketStart == null ? "—" : formatTooltipLabel(first.bucketStart); + return first?.bucketStart == null + ? "—" + : formatTooltipLabel(first.bucketStart, props.bucketMs); }} /> } diff --git a/packages/plugins/execution-history/src/react/view-options.tsx b/packages/plugins/execution-history/src/react/view-options.tsx index 37456a907..05d6e7537 100644 --- a/packages/plugins/execution-history/src/react/view-options.tsx +++ b/packages/plugins/execution-history/src/react/view-options.tsx @@ -11,6 +11,7 @@ const COLUMN_KEYS: readonly RunColumnKey[] = [ "duration", "tools", "interaction", + "log", ]; export function ViewOptions(props: { diff --git a/packages/plugins/execution-history/src/react/view.ts b/packages/plugins/execution-history/src/react/view.ts index b8bf27019..c19aae9c4 100644 --- a/packages/plugins/execution-history/src/react/view.ts +++ b/packages/plugins/execution-history/src/react/view.ts @@ -10,6 +10,7 @@ export interface RunColumns { readonly duration: boolean; readonly tools: boolean; readonly interaction: boolean; + readonly log: boolean; } export type RunColumnKey = keyof RunColumns; @@ -20,6 +21,7 @@ export const DEFAULT_COLUMNS: RunColumns = { duration: true, tools: true, interaction: true, + log: false, }; export const RUN_COLUMN_LABELS: Record = { @@ -28,4 +30,5 @@ export const RUN_COLUMN_LABELS: Record = { duration: "Duration", tools: "Tools", interaction: "Interaction", + log: "Log", }; From b910f5c0ce8523d6ba05fcc5934af8323e67f615 Mon Sep 17 00:00:00 2001 From: Saatvik Arya Date: Mon, 15 Jun 2026 02:28:59 +0530 Subject: [PATCH 2/2] fix(execution-history): address drawer parity review findings - HoverCardTimestamp: closeDelay 0 -> 150ms so the cursor can travel from the trigger to the copy rows without the card closing (Radix HoverCard has no safe-polygon grace area). - Column persistence stores explicit key=0/1 pairs and reads unknown keys from DEFAULT_COLUMNS, so a column added in a later release keeps its default visibility instead of being locked hidden for existing users. --- .../execution-history/src/react/RunsPage.tsx | 15 ++++++++++----- .../src/react/hover-card-timestamp.tsx | 2 +- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/packages/plugins/execution-history/src/react/RunsPage.tsx b/packages/plugins/execution-history/src/react/RunsPage.tsx index 12f81cb9f..931a27871 100644 --- a/packages/plugins/execution-history/src/react/RunsPage.tsx +++ b/packages/plugins/execution-history/src/react/RunsPage.tsx @@ -46,14 +46,20 @@ const readStoredColumns = (): RunColumns => { if (typeof window === "undefined") return DEFAULT_COLUMNS; const raw = window.localStorage.getItem(COLUMNS_STORAGE_KEY); if (raw == null) return DEFAULT_COLUMNS; - const visible = new Set(raw.split(",").filter(Boolean)); + // Start from the current defaults and override only the keys actually stored, + // so a column added in a later release keeps its default visibility instead + // of being read as hidden for existing users. const next = { ...DEFAULT_COLUMNS }; - for (const key of Object.keys(next) as RunColumnKey[]) { - next[key] = visible.has(key); + for (const pair of raw.split(",")) { + const [key, value] = pair.split("="); + if (key != null && key in next) next[key as RunColumnKey] = value === "1"; } return next; }; +const serializeColumns = (columns: RunColumns): string => + (Object.keys(columns) as RunColumnKey[]).map((key) => `${key}=${columns[key] ? 1 : 0}`).join(","); + export function RunsPage() { const [filters, setFilters] = useState(emptyRunsFilters); const [live, setLive] = useState(false); @@ -80,8 +86,7 @@ export function RunsPage() { useEffect(() => { if (typeof window === "undefined") return; - const visible = (Object.keys(columns) as RunColumnKey[]).filter((key) => columns[key]); - window.localStorage.setItem(COLUMNS_STORAGE_KEY, visible.join(",")); + window.localStorage.setItem(COLUMNS_STORAGE_KEY, serializeColumns(columns)); }, [columns]); // Keep the latest view/selected reachable from a stable keydown listener. diff --git a/packages/plugins/execution-history/src/react/hover-card-timestamp.tsx b/packages/plugins/execution-history/src/react/hover-card-timestamp.tsx index 2fe8535c9..1d18d771a 100644 --- a/packages/plugins/execution-history/src/react/hover-card-timestamp.tsx +++ b/packages/plugins/execution-history/src/react/hover-card-timestamp.tsx @@ -49,7 +49,7 @@ export function HoverCardTimestamp({ className, }: HoverCardTimestampProps) { return ( - + {display ?? absolute(timestamp)}