feat(webapp): show a full multi-row column preview beside the sample

Replace the single 'Resolves to' cell with a Preview column that renders
the smart column exactly as the table would, one cell per recent run
(number/badge/duration formatting, offloaded and empty states, dotted
in-flight values). Give the sample JSON its own taller column, and share
the cell renderer between the table and the preview.
This commit is contained in:
Eric Allam
2026-08-17 21:47:54 +01:00
parent 5d83356c6d
commit 522c8cdba4
4 changed files with 154 additions and 103 deletions
@@ -24,6 +24,7 @@ import {
type ParsedSource,
} from "./smartColumnData";
import { SmartColumnSample } from "./SmartColumnSample";
import { isNumericSmartDisplay, SmartCellContent } from "./smartColumnCell";
import type { loader as sampleLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample";
type AddSmartColumnDialogProps = {
@@ -98,37 +99,40 @@ export function AddSmartColumnDialog({
const sampleLoaded = sample.data !== undefined && sample.state === "idle";
const sampleData = sample.data;
const { usable, anyOffloaded, runCount } = useMemo(() => {
const { perRun, usable, anyOffloaded, runCount } = useMemo(() => {
const runs = sampleData?.runs ?? [];
const parsed = runs.map((run) => {
switch (source) {
case "payload":
return parseSource({ data: run.payload, dataType: run.payloadType });
case "metadata":
return parseSource({ data: run.metadata, dataType: run.metadataType });
case "output":
return parseSource({ data: run.output, dataType: run.outputType });
}
});
const perRun = runs.map((run) => ({
hasFinished: run.hasFinished,
parsed:
source === "payload"
? parseSource({ data: run.payload, dataType: run.payloadType })
: source === "metadata"
? parseSource({ data: run.metadata, dataType: run.metadataType })
: parseSource({ data: run.output, dataType: run.outputType }),
}));
return {
runCount: runs.length,
anyOffloaded: parsed.some((p) => p.state === "offloaded"),
usable: parsed.filter(
(p): p is Extract<ParsedSource, { state: "parsed" }> => p.state === "parsed"
perRun,
anyOffloaded: perRun.some((r) => r.parsed.state === "offloaded"),
usable: perRun.filter(
(r): r is { hasFinished: boolean; parsed: Extract<ParsedSource, { state: "parsed" }> } =>
r.parsed.state === "parsed"
),
};
}, [sampleData, source]);
const activeIndex = usable.length > 0 ? Math.min(sampleIndex, usable.length - 1) : 0;
const activeSample = usable[activeIndex];
const resolved = useMemo(() => {
if (!activeSample || path.trim().length === 0) return undefined;
return extractSmartValue(activeSample, path);
}, [activeSample, path]);
const activeSample = usable[activeIndex]?.parsed;
const canSubmit = path.trim().length > 0;
const previewDef: SmartColumnDef = {
source,
path: path.trim(),
label: effectiveLabel,
displayAs,
};
const handleSubmit = () => {
if (!canSubmit) return;
onSubmit({ source, path: path.trim(), label: effectiveLabel.trim() || path.trim(), displayAs });
@@ -137,7 +141,7 @@ export function AddSmartColumnDialog({
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[820px]!">
<DialogContent className="sm:max-w-[1040px]!">
<DialogHeader>{editing ? "Edit smart column" : "Add smart column"}</DialogHeader>
<div className="flex flex-col gap-5 p-1">
<Callout variant="info">
@@ -145,7 +149,7 @@ export function AddSmartColumnDialog({
the list by it. To narrow the list, use tags or the query editor.
</Callout>
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_300px]">
<div className="grid grid-cols-1 gap-5 md:grid-cols-[minmax(0,1fr)_260px_220px]">
<div className="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label>Source</Label>
@@ -253,10 +257,11 @@ export function AddSmartColumnDialog({
No recent run has a {source} value to sample.
</Paragraph>
)}
<Paragraph variant="extra-extra-small/dimmed/caps" className="mt-2">
Resolves to
</Paragraph>
<SmartColumnResolvedPreview label={effectiveLabel} resolved={resolved} />
</div>
<div className="flex flex-col gap-1.5 self-start">
<Paragraph variant="extra-extra-small/dimmed/caps">Preview</Paragraph>
<SmartColumnPreview rows={perRun} def={previewDef} loaded={sampleLoaded} />
</div>
</div>
</div>
@@ -347,27 +352,50 @@ function SourceCard({
);
}
function SmartColumnResolvedPreview({
label,
resolved,
function SmartColumnPreview({
rows,
def,
loaded,
}: {
label: string;
resolved: ReturnType<typeof extractSmartValue> | undefined;
rows: { hasFinished: boolean; parsed: ParsedSource }[];
def: SmartColumnDef;
loaded: boolean;
}) {
let value: string;
if (!resolved) value = "";
else if (resolved.state === "offloaded") value = "Too large";
else if (resolved.state === "empty") value = "";
else if (typeof resolved.value === "object") value = JSON.stringify(resolved.value);
else value = String(resolved.value);
const numeric = isNumericSmartDisplay(def.displayAs);
const alignClass = numeric ? "justify-end text-right tabular-nums" : "justify-start text-left";
return (
<div className="rounded border border-grid-dimmed">
<div className="flex items-center gap-1 border-b border-grid-dimmed px-2 py-1">
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
<div className="flex items-center gap-1 border-b border-grid-dimmed bg-background-dimmed px-2.5 py-1.5">
<BoltIcon className="size-3.5 flex-none text-text-dimmed" />
<span className="truncate text-xs text-text-bright">{label || "Column"}</span>
<span className="truncate text-xs font-medium text-text-bright">
{def.label || "Column"}
</span>
</div>
<div className="max-h-80 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
{!loaded ? (
<div className="px-2.5 py-2 text-xs text-text-dimmed">Loading</div>
) : rows.length === 0 ? (
<div className="px-2.5 py-2 text-xs text-text-dimmed">No runs</div>
) : (
rows.map((row, index) => {
const cell = def.path
? extractSmartValue(row.parsed, def.path)
: ({ state: "empty" } as const);
return (
<div
key={index}
className={cn(
"flex h-8 items-center border-b border-grid-dimmed/60 px-2.5 text-sm last:border-b-0",
alignClass
)}
>
<SmartCellContent cell={cell} def={def} provisional={!row.hasFinished} />
</div>
);
})
)}
</div>
<div className="px-2 py-1.5 text-right text-sm tabular-nums text-text-bright">{value}</div>
</div>
);
}
@@ -20,7 +20,7 @@ export function SmartColumnSample({
onSelectPath: (path: string) => void;
}) {
return (
<div className="max-h-52 overflow-auto rounded bg-charcoal-900 p-2 font-mono text-xs leading-relaxed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<div className="max-h-80 overflow-auto rounded bg-charcoal-900 p-2 font-mono text-xs leading-relaxed scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
<JsonNode
name={undefined}
path="$"
@@ -34,7 +34,6 @@ import {
} from "~/presenters/v3/NextRunListPresenter.server";
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
import { docsPath, v3RunSpanPath, v3TestPath, v3TestTaskPath } from "~/utils/pathBuilder";
import { cn } from "~/utils/cn";
import { DateTime } from "../../primitives/DateTime";
import { Paragraph } from "../../primitives/Paragraph";
import { Spinner } from "../../primitives/Spinner";
@@ -75,6 +74,7 @@ import {
type SmartColumnSource,
} from "./runColumns";
import { extractSmartValue, parseSource, type ParsedSource } from "./smartColumnData";
import { isNumericSmartDisplay, SmartCellContent } from "./smartColumnCell";
type RunsTableProps = {
total: number;
@@ -490,36 +490,6 @@ function SmartColumnHeader({ def }: { def: SmartColumnDef }) {
);
}
function stringifySmartValue(value: unknown): string {
if (value === null) return "null";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function renderSmartValue(value: unknown, def: SmartColumnDef): React.ReactNode {
switch (def.displayAs) {
case "number": {
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n.toLocaleString() : stringifySmartValue(value);
}
case "duration": {
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n)
? formatDurationMilliseconds(n, { style: "short" })
: stringifySmartValue(value);
}
case "badge":
return <Badge variant="extra-small">{stringifySmartValue(value)}</Badge>;
default:
return stringifySmartValue(value);
}
}
function SmartColumnCell({
def,
run,
@@ -531,40 +501,12 @@ function SmartColumnCell({
path: string;
parsed: ParsedSource | undefined;
}) {
const numeric = def.displayAs === "number" || def.displayAs === "duration";
const numeric = isNumericSmartDisplay(def.displayAs);
const cell = extractSmartValue(parsed ?? { state: "empty" }, def.path);
if (cell.state === "offloaded") {
return (
<TableCell to={path}>
<SimpleTooltip
disableHoverableContent
button={
<span className="border-b border-dotted border-amber-500/60 text-amber-500">
Too large
</span>
}
content={`This run's ${def.source} is offloaded to object storage instead of the run row. Open the run to read it.`}
/>
</TableCell>
);
}
if (cell.state === "empty") {
return (
<TableCell to={path} className={numeric ? "tabular-nums" : undefined}>
</TableCell>
);
}
const provisional = !run.hasFinished;
return (
<TableCell to={path} className={numeric ? "text-right tabular-nums" : undefined}>
<span className={cn(provisional && "border-b border-dotted border-text-dimmed/50")}>
{renderSmartValue(cell.value, def)}
</span>
<SmartCellContent cell={cell} def={def} provisional={!run.hasFinished} />
</TableCell>
);
}
@@ -0,0 +1,81 @@
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
import { Badge } from "~/components/primitives/Badge";
import { SimpleTooltip } from "~/components/primitives/Tooltip";
import { cn } from "~/utils/cn";
import type { SmartColumnDef } from "./runColumns";
import type { SmartCellValue } from "./smartColumnData";
/** Number and duration columns right-align and use tabular figures. */
export function isNumericSmartDisplay(display: SmartColumnDef["displayAs"]): boolean {
return display === "number" || display === "duration";
}
function stringifySmartValue(value: unknown): string {
if (value === null) return "null";
if (typeof value === "string") return value;
if (typeof value === "number" || typeof value === "boolean") return String(value);
try {
return JSON.stringify(value);
} catch {
return String(value);
}
}
function renderSmartValue(value: unknown, displayAs: SmartColumnDef["displayAs"]): React.ReactNode {
switch (displayAs) {
case "number": {
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n) ? n.toLocaleString() : stringifySmartValue(value);
}
case "duration": {
const n = typeof value === "number" ? value : Number(value);
return Number.isFinite(n)
? formatDurationMilliseconds(n, { style: "short" })
: stringifySmartValue(value);
}
case "badge":
return <Badge variant="extra-small">{stringifySmartValue(value)}</Badge>;
default:
return stringifySmartValue(value);
}
}
/**
* The inner content of a smart-column cell (no table/row wrapper), shared by the
* runs table and the add-column preview so both look identical. `offloaded`
* shows a "Too large" tooltip, an absent path shows "", and an in-flight run's
* value is dotted-underlined to mark it provisional.
*/
export function SmartCellContent({
cell,
def,
provisional,
}: {
cell: SmartCellValue;
def: SmartColumnDef;
provisional: boolean;
}) {
if (cell.state === "offloaded") {
return (
<SimpleTooltip
disableHoverableContent
button={
<span className="border-b border-dotted border-amber-500/60 text-amber-500">
Too large
</span>
}
content={`This run's ${def.source} is offloaded to object storage instead of the run row. Open the run to read it.`}
/>
);
}
if (cell.state === "empty") {
return <span className="text-text-dimmed"></span>;
}
return (
<span className={cn(provisional && "border-b border-dotted border-text-dimmed/50")}>
{renderSmartValue(cell.value, def.displayAs)}
</span>
);
}