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
39 changes: 38 additions & 1 deletion packages/plugins/execution-history/src/react/RunsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,11 +36,35 @@ 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;
// 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 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<RunsFilters>(emptyRunsFilters);
const [live, setLive] = useState(false);
const [selected, setSelected] = useState<string | null>(null);
const [columns, setColumns] = useState<RunColumns>(DEFAULT_COLUMNS);
const [columns, setColumns] = useState<RunColumns>(readStoredColumns);
const [commandOpen, setCommandOpen] = useState(false);
const [helpOpen, setHelpOpen] = useState(false);
const [railCollapsed, setRailCollapsed] = useState(false);
Expand All @@ -60,6 +84,11 @@ export function RunsPage() {
setColumns((current) => ({ ...current, [key]: !current[key] }));
}, []);

useEffect(() => {
if (typeof window === "undefined") return;
window.localStorage.setItem(COLUMNS_STORAGE_KEY, serializeColumns(columns));
}, [columns]);
Comment thread
greptile-apps[bot] marked this conversation as resolved.

// Keep the latest view/selected reachable from a stable keydown listener.
const shortcutRef = useRef<ShortcutContext>({ view, selected });
shortcutRef.current = { view, selected };
Expand Down Expand Up @@ -153,6 +182,11 @@ export function RunsPage() {
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex min-w-0 items-center gap-3">
<h1 className="text-sm font-semibold">Runs</h1>
{view.meta != null && (
<span className="font-mono text-[11px] tabular-nums text-muted-foreground/70">
{view.rows.length} of {view.meta.filterRowCount}
</span>
)}
<Button
type="button"
variant="outline"
Expand Down Expand Up @@ -237,6 +271,9 @@ export function RunsPage() {
emptyState={
<div className="p-6 text-center font-mono text-xs text-muted-foreground">
No runs match the current filters.
<span className="mt-1 block text-muted-foreground/60">
Try widening the time range or clearing a filter.
</span>
</div>
}
railCollapsed={railCollapsed}
Expand Down
12 changes: 11 additions & 1 deletion packages/plugins/execution-history/src/react/column-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -107,6 +114,9 @@ export function RunsColumnHeader({
{/* Interaction */}
{columns.interaction ? <span className={COL_INTERACTION}>interaction</span> : null}

{/* Log */}
{columns.log ? <span className={COL_LOG}>log</span> : null}

{/* Code */}
<span className="min-w-0 flex-1 truncate">code</span>
</div>
Expand Down
13 changes: 11 additions & 2 deletions packages/plugins/execution-history/src/react/detail-drawer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -340,8 +341,16 @@ function DetailContent(props: {
<span className="text-muted-foreground/60">—</span>
)}
</MetaCard>
<MetaCard label="Started">{formatDateTime(run.startedAt)}</MetaCard>
<MetaCard label="Completed">{formatDateTime(run.completedAt)}</MetaCard>
<MetaCard label="Started">
<HoverCardTimestamp timestamp={run.startedAt} side="bottom" />
</MetaCard>
<MetaCard label="Completed">
{run.completedAt != null ? (
<HoverCardTimestamp timestamp={run.completedAt} side="bottom" />
) : (
formatDateTime(run.completedAt)
)}
</MetaCard>
</div>

<div className="flex items-center gap-2 text-xs">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down
104 changes: 104 additions & 0 deletions packages/plugins/execution-history/src/react/hover-card-timestamp.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<HoverCard openDelay={0} closeDelay={150}>
<HoverCardTrigger asChild>
<span className={cn("font-mono whitespace-nowrap", className)}>
{display ?? absolute(timestamp)}
</span>
</HoverCardTrigger>
<HoverCardContent side={side} align={align} alignOffset={-4} className="z-50 w-auto p-2">
<dl className="flex flex-col gap-1">
<CopyRow label="Timestamp" value={String(timestamp)} />
<CopyRow label="UTC" value={absolute(timestamp, "UTC")} />
<CopyRow label={LOCAL_TZ} value={absolute(timestamp)} />
<CopyRow label="Relative" value={formatRelative(timestamp)} />
</dl>
</HoverCardContent>
</HoverCard>
);
}

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 (
<div
role="button"
tabIndex={0}
onClick={copy}
onKeyDown={(event) => {
if (event.key === "Enter" || event.key === " ") {
event.preventDefault();
copy(event);
}
}}
className="group flex items-center justify-between gap-4 text-xs"
>
<dt className="text-muted-foreground">{label}</dt>
<dd className="flex items-center gap-1 truncate font-mono">
<span className="invisible group-hover:visible">
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
</span>
{value}
</dd>
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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}
>
Expand Down
53 changes: 44 additions & 9 deletions packages/plugins/execution-history/src/react/run-row.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -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 (
<Button
Expand All @@ -60,12 +66,11 @@ export function RunListRow({ run, selected, isPast, columns, onSelect }: RunList
/>

{/* Started */}
<span
title={formatDateTime(run.startedAt)}
<HoverCardTimestamp
timestamp={run.startedAt}
display={formatRelative(run.startedAt)}
className="w-[150px] shrink-0 tabular-nums text-muted-foreground md:w-[190px]"
>
{formatRelative(run.startedAt)}
</span>
/>

{/* Status label */}
<span className={cn("inline-flex w-[120px] shrink-0 gap-1", tone.text)}>
Expand Down Expand Up @@ -104,9 +109,17 @@ export function RunListRow({ run, selected, isPast, columns, onSelect }: RunList
</span>
) : null}

{/* Duration (optional) */}
{/* Duration (optional) — slow runs (>5s) flagged. */}
{columns.duration ? (
<span className={cn(COL_DURATION, "tabular-nums text-muted-foreground")}>
<span
className={cn(
COL_DURATION,
"tabular-nums",
run.durationMs != null && run.durationMs > 5000
? "text-destructive"
: "text-muted-foreground",
)}
>
{formatDuration(run.durationMs)}
</span>
) : null}
Expand Down Expand Up @@ -140,11 +153,33 @@ export function RunListRow({ run, selected, isPast, columns, onSelect }: RunList
</span>
) : null}

{/* Log error/warn counts (optional) */}
{columns.log ? (
<span className={cn(COL_LOG, "tabular-nums")}>
{logs.length === 0 ? (
<span className="text-muted-foreground/50">—</span>
) : (
<span className="inline-flex gap-1.5">
<span className={logErrors > 0 ? "text-destructive" : "text-muted-foreground/60"}>
{logErrors}E
</span>
<span
className={
logWarns > 0 ? "text-amber-600 dark:text-amber-300" : "text-muted-foreground/60"
}
>
{logWarns}W
</span>
</span>
)}
</span>
) : null}

{/* Code snippet (always visible, fills remaining space) */}
<span className="min-w-0 flex-1 truncate text-muted-foreground">
<span className="text-muted-foreground/50">code: </span>
<span className="text-foreground/70">
&quot;{run.code.trim().replace(/\s+/g, " ").slice(0, 80)}&quot;
&quot;{run.code.trim().replace(/\s+/g, " ").slice(0, 160)}&quot;
</span>
</span>
</Button>
Expand Down
5 changes: 5 additions & 0 deletions packages/plugins/execution-history/src/react/shell.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,11 @@ export function RunsShell(props: RunsShellProps) {
Loading more…
</div>
)}
{!hasMore && !isLoadingMore && (
<div className="flex w-full items-center justify-center border-t border-border/40 py-3 font-mono text-[11px] uppercase tracking-wider text-muted-foreground/40">
End of history
</div>
)}
</>
)}
</div>
Expand Down
Loading
Loading