Skip to content

Commit 7059969

Browse files
committed
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.
1 parent 9782726 commit 7059969

4 files changed

Lines changed: 154 additions & 103 deletions

File tree

apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx

Lines changed: 69 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
type ParsedSource,
2525
} from "./smartColumnData";
2626
import { SmartColumnSample } from "./SmartColumnSample";
27+
import { isNumericSmartDisplay, SmartCellContent } from "./smartColumnCell";
2728
import type { loader as sampleLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample";
2829

2930
type AddSmartColumnDialogProps = {
@@ -98,37 +99,40 @@ export function AddSmartColumnDialog({
9899
const sampleLoaded = sample.data !== undefined && sample.state === "idle";
99100
const sampleData = sample.data;
100101

101-
const { usable, anyOffloaded, runCount } = useMemo(() => {
102+
const { perRun, usable, anyOffloaded, runCount } = useMemo(() => {
102103
const runs = sampleData?.runs ?? [];
103-
const parsed = runs.map((run) => {
104-
switch (source) {
105-
case "payload":
106-
return parseSource({ data: run.payload, dataType: run.payloadType });
107-
case "metadata":
108-
return parseSource({ data: run.metadata, dataType: run.metadataType });
109-
case "output":
110-
return parseSource({ data: run.output, dataType: run.outputType });
111-
}
112-
});
104+
const perRun = runs.map((run) => ({
105+
hasFinished: run.hasFinished,
106+
parsed:
107+
source === "payload"
108+
? parseSource({ data: run.payload, dataType: run.payloadType })
109+
: source === "metadata"
110+
? parseSource({ data: run.metadata, dataType: run.metadataType })
111+
: parseSource({ data: run.output, dataType: run.outputType }),
112+
}));
113113
return {
114114
runCount: runs.length,
115-
anyOffloaded: parsed.some((p) => p.state === "offloaded"),
116-
usable: parsed.filter(
117-
(p): p is Extract<ParsedSource, { state: "parsed" }> => p.state === "parsed"
115+
perRun,
116+
anyOffloaded: perRun.some((r) => r.parsed.state === "offloaded"),
117+
usable: perRun.filter(
118+
(r): r is { hasFinished: boolean; parsed: Extract<ParsedSource, { state: "parsed" }> } =>
119+
r.parsed.state === "parsed"
118120
),
119121
};
120122
}, [sampleData, source]);
121123

122124
const activeIndex = usable.length > 0 ? Math.min(sampleIndex, usable.length - 1) : 0;
123-
const activeSample = usable[activeIndex];
124-
125-
const resolved = useMemo(() => {
126-
if (!activeSample || path.trim().length === 0) return undefined;
127-
return extractSmartValue(activeSample, path);
128-
}, [activeSample, path]);
125+
const activeSample = usable[activeIndex]?.parsed;
129126

130127
const canSubmit = path.trim().length > 0;
131128

129+
const previewDef: SmartColumnDef = {
130+
source,
131+
path: path.trim(),
132+
label: effectiveLabel,
133+
displayAs,
134+
};
135+
132136
const handleSubmit = () => {
133137
if (!canSubmit) return;
134138
onSubmit({ source, path: path.trim(), label: effectiveLabel.trim() || path.trim(), displayAs });
@@ -137,15 +141,15 @@ export function AddSmartColumnDialog({
137141

138142
return (
139143
<Dialog open={open} onOpenChange={onOpenChange}>
140-
<DialogContent className="sm:max-w-[820px]!">
144+
<DialogContent className="sm:max-w-[1040px]!">
141145
<DialogHeader>{editing ? "Edit smart column" : "Add smart column"}</DialogHeader>
142146
<div className="flex flex-col gap-5 p-1">
143147
<Callout variant="info">
144148
Display only. A smart column shows you a value from a run, but you can't sort or filter
145149
the list by it. To narrow the list, use tags or the query editor.
146150
</Callout>
147151

148-
<div className="grid grid-cols-1 gap-6 md:grid-cols-[1fr_300px]">
152+
<div className="grid grid-cols-1 gap-5 md:grid-cols-[minmax(0,1fr)_260px_220px]">
149153
<div className="flex flex-col gap-5">
150154
<div className="flex flex-col gap-1.5">
151155
<Label>Source</Label>
@@ -253,10 +257,11 @@ export function AddSmartColumnDialog({
253257
No recent run has a {source} value to sample.
254258
</Paragraph>
255259
)}
256-
<Paragraph variant="extra-extra-small/dimmed/caps" className="mt-2">
257-
Resolves to
258-
</Paragraph>
259-
<SmartColumnResolvedPreview label={effectiveLabel} resolved={resolved} />
260+
</div>
261+
262+
<div className="flex flex-col gap-1.5 self-start">
263+
<Paragraph variant="extra-extra-small/dimmed/caps">Preview</Paragraph>
264+
<SmartColumnPreview rows={perRun} def={previewDef} loaded={sampleLoaded} />
260265
</div>
261266
</div>
262267
</div>
@@ -347,27 +352,50 @@ function SourceCard({
347352
);
348353
}
349354

350-
function SmartColumnResolvedPreview({
351-
label,
352-
resolved,
355+
function SmartColumnPreview({
356+
rows,
357+
def,
358+
loaded,
353359
}: {
354-
label: string;
355-
resolved: ReturnType<typeof extractSmartValue> | undefined;
360+
rows: { hasFinished: boolean; parsed: ParsedSource }[];
361+
def: SmartColumnDef;
362+
loaded: boolean;
356363
}) {
357-
let value: string;
358-
if (!resolved) value = "–";
359-
else if (resolved.state === "offloaded") value = "Too large";
360-
else if (resolved.state === "empty") value = "–";
361-
else if (typeof resolved.value === "object") value = JSON.stringify(resolved.value);
362-
else value = String(resolved.value);
364+
const numeric = isNumericSmartDisplay(def.displayAs);
365+
const alignClass = numeric ? "justify-end text-right tabular-nums" : "justify-start text-left";
363366

364367
return (
365-
<div className="rounded border border-grid-dimmed">
366-
<div className="flex items-center gap-1 border-b border-grid-dimmed px-2 py-1">
368+
<div className="overflow-hidden rounded-lg border border-grid-dimmed">
369+
<div className="flex items-center gap-1 border-b border-grid-dimmed bg-background-dimmed px-2.5 py-1.5">
367370
<BoltIcon className="size-3.5 flex-none text-text-dimmed" />
368-
<span className="truncate text-xs text-text-bright">{label || "Column"}</span>
371+
<span className="truncate text-xs font-medium text-text-bright">
372+
{def.label || "Column"}
373+
</span>
374+
</div>
375+
<div className="max-h-80 overflow-auto scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
376+
{!loaded ? (
377+
<div className="px-2.5 py-2 text-xs text-text-dimmed">Loading…</div>
378+
) : rows.length === 0 ? (
379+
<div className="px-2.5 py-2 text-xs text-text-dimmed">No runs</div>
380+
) : (
381+
rows.map((row, index) => {
382+
const cell = def.path
383+
? extractSmartValue(row.parsed, def.path)
384+
: ({ state: "empty" } as const);
385+
return (
386+
<div
387+
key={index}
388+
className={cn(
389+
"flex h-8 items-center border-b border-grid-dimmed/60 px-2.5 text-sm last:border-b-0",
390+
alignClass
391+
)}
392+
>
393+
<SmartCellContent cell={cell} def={def} provisional={!row.hasFinished} />
394+
</div>
395+
);
396+
})
397+
)}
369398
</div>
370-
<div className="px-2 py-1.5 text-right text-sm tabular-nums text-text-bright">{value}</div>
371399
</div>
372400
);
373401
}

apps/webapp/app/components/runs/v3/SmartColumnSample.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@ export function SmartColumnSample({
2020
onSelectPath: (path: string) => void;
2121
}) {
2222
return (
23-
<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">
23+
<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">
2424
<JsonNode
2525
name={undefined}
2626
path="$"

apps/webapp/app/components/runs/v3/TaskRunsTable.tsx

Lines changed: 3 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ import {
3434
} from "~/presenters/v3/NextRunListPresenter.server";
3535
import { formatCurrencyAccurate } from "~/utils/numberFormatter";
3636
import { docsPath, v3RunSpanPath, v3TestPath, v3TestTaskPath } from "~/utils/pathBuilder";
37-
import { cn } from "~/utils/cn";
3837
import { DateTime } from "../../primitives/DateTime";
3938
import { Paragraph } from "../../primitives/Paragraph";
4039
import { Spinner } from "../../primitives/Spinner";
@@ -75,6 +74,7 @@ import {
7574
type SmartColumnSource,
7675
} from "./runColumns";
7776
import { extractSmartValue, parseSource, type ParsedSource } from "./smartColumnData";
77+
import { isNumericSmartDisplay, SmartCellContent } from "./smartColumnCell";
7878

7979
type RunsTableProps = {
8080
total: number;
@@ -490,36 +490,6 @@ function SmartColumnHeader({ def }: { def: SmartColumnDef }) {
490490
);
491491
}
492492

493-
function stringifySmartValue(value: unknown): string {
494-
if (value === null) return "null";
495-
if (typeof value === "string") return value;
496-
if (typeof value === "number" || typeof value === "boolean") return String(value);
497-
try {
498-
return JSON.stringify(value);
499-
} catch {
500-
return String(value);
501-
}
502-
}
503-
504-
function renderSmartValue(value: unknown, def: SmartColumnDef): React.ReactNode {
505-
switch (def.displayAs) {
506-
case "number": {
507-
const n = typeof value === "number" ? value : Number(value);
508-
return Number.isFinite(n) ? n.toLocaleString() : stringifySmartValue(value);
509-
}
510-
case "duration": {
511-
const n = typeof value === "number" ? value : Number(value);
512-
return Number.isFinite(n)
513-
? formatDurationMilliseconds(n, { style: "short" })
514-
: stringifySmartValue(value);
515-
}
516-
case "badge":
517-
return <Badge variant="extra-small">{stringifySmartValue(value)}</Badge>;
518-
default:
519-
return stringifySmartValue(value);
520-
}
521-
}
522-
523493
function SmartColumnCell({
524494
def,
525495
run,
@@ -531,40 +501,12 @@ function SmartColumnCell({
531501
path: string;
532502
parsed: ParsedSource | undefined;
533503
}) {
534-
const numeric = def.displayAs === "number" || def.displayAs === "duration";
504+
const numeric = isNumericSmartDisplay(def.displayAs);
535505
const cell = extractSmartValue(parsed ?? { state: "empty" }, def.path);
536506

537-
if (cell.state === "offloaded") {
538-
return (
539-
<TableCell to={path}>
540-
<SimpleTooltip
541-
disableHoverableContent
542-
button={
543-
<span className="border-b border-dotted border-amber-500/60 text-amber-500">
544-
Too large
545-
</span>
546-
}
547-
content={`This run's ${def.source} is offloaded to object storage instead of the run row. Open the run to read it.`}
548-
/>
549-
</TableCell>
550-
);
551-
}
552-
553-
if (cell.state === "empty") {
554-
return (
555-
<TableCell to={path} className={numeric ? "tabular-nums" : undefined}>
556-
557-
</TableCell>
558-
);
559-
}
560-
561-
const provisional = !run.hasFinished;
562-
563507
return (
564508
<TableCell to={path} className={numeric ? "text-right tabular-nums" : undefined}>
565-
<span className={cn(provisional && "border-b border-dotted border-text-dimmed/50")}>
566-
{renderSmartValue(cell.value, def)}
567-
</span>
509+
<SmartCellContent cell={cell} def={def} provisional={!run.hasFinished} />
568510
</TableCell>
569511
);
570512
}
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { formatDurationMilliseconds } from "@trigger.dev/core/v3";
2+
import { Badge } from "~/components/primitives/Badge";
3+
import { SimpleTooltip } from "~/components/primitives/Tooltip";
4+
import { cn } from "~/utils/cn";
5+
import type { SmartColumnDef } from "./runColumns";
6+
import type { SmartCellValue } from "./smartColumnData";
7+
8+
/** Number and duration columns right-align and use tabular figures. */
9+
export function isNumericSmartDisplay(display: SmartColumnDef["displayAs"]): boolean {
10+
return display === "number" || display === "duration";
11+
}
12+
13+
function stringifySmartValue(value: unknown): string {
14+
if (value === null) return "null";
15+
if (typeof value === "string") return value;
16+
if (typeof value === "number" || typeof value === "boolean") return String(value);
17+
try {
18+
return JSON.stringify(value);
19+
} catch {
20+
return String(value);
21+
}
22+
}
23+
24+
function renderSmartValue(value: unknown, displayAs: SmartColumnDef["displayAs"]): React.ReactNode {
25+
switch (displayAs) {
26+
case "number": {
27+
const n = typeof value === "number" ? value : Number(value);
28+
return Number.isFinite(n) ? n.toLocaleString() : stringifySmartValue(value);
29+
}
30+
case "duration": {
31+
const n = typeof value === "number" ? value : Number(value);
32+
return Number.isFinite(n)
33+
? formatDurationMilliseconds(n, { style: "short" })
34+
: stringifySmartValue(value);
35+
}
36+
case "badge":
37+
return <Badge variant="extra-small">{stringifySmartValue(value)}</Badge>;
38+
default:
39+
return stringifySmartValue(value);
40+
}
41+
}
42+
43+
/**
44+
* The inner content of a smart-column cell (no table/row wrapper), shared by the
45+
* runs table and the add-column preview so both look identical. `offloaded`
46+
* shows a "Too large" tooltip, an absent path shows "–", and an in-flight run's
47+
* value is dotted-underlined to mark it provisional.
48+
*/
49+
export function SmartCellContent({
50+
cell,
51+
def,
52+
provisional,
53+
}: {
54+
cell: SmartCellValue;
55+
def: SmartColumnDef;
56+
provisional: boolean;
57+
}) {
58+
if (cell.state === "offloaded") {
59+
return (
60+
<SimpleTooltip
61+
disableHoverableContent
62+
button={
63+
<span className="border-b border-dotted border-amber-500/60 text-amber-500">
64+
Too large
65+
</span>
66+
}
67+
content={`This run's ${def.source} is offloaded to object storage instead of the run row. Open the run to read it.`}
68+
/>
69+
);
70+
}
71+
72+
if (cell.state === "empty") {
73+
return <span className="text-text-dimmed"></span>;
74+
}
75+
76+
return (
77+
<span className={cn(provisional && "border-b border-dotted border-text-dimmed/50")}>
78+
{renderSmartValue(cell.value, def.displayAs)}
79+
</span>
80+
);
81+
}

0 commit comments

Comments
 (0)