Skip to content
Open
Show file tree
Hide file tree
Changes from 14 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
10b7949
feat(webapp): runs-list column registry, URL codec, and smart-column …
ericallam Aug 17, 2026
aaa4f28
feat(webapp): derive the runs-list Postgres select from visible columns
ericallam Aug 17, 2026
6a18301
feat(webapp): column display options and smart columns on the runs list
ericallam Aug 17, 2026
76c8303
feat(webapp): keep smart-column values fresh in the runs live poll
ericallam Aug 17, 2026
90d3fbe
docs(webapp): add server-changes note for runs-list column customization
ericallam Aug 17, 2026
efe0ffd
feat(webapp): editable smart columns and display-options polish
ericallam Aug 17, 2026
5b4371f
feat(webapp): stable column toggle and a clearer smart-column icon
ericallam Aug 17, 2026
dcb7287
feat(webapp): use a bolt icon for smart columns and reveal row action…
ericallam Aug 17, 2026
918ef18
feat(webapp): compact column URL state
ericallam Aug 17, 2026
f86fc8d
feat(webapp): redesign the add-smart-column modal
ericallam Aug 17, 2026
55586b5
feat(webapp): syntax-highlight the smart-column sample and cap large …
ericallam Aug 17, 2026
8604a03
feat(webapp): click a key in the smart-column sample to fill the path
ericallam Aug 17, 2026
dd8d3d4
feat(webapp): only leaf values are selectable in the sample tree
ericallam Aug 17, 2026
f943099
feat(webapp): expand the sample tree and let it page through recent runs
ericallam Aug 17, 2026
76ca32b
feat(webapp): trim smart-column sample chrome
ericallam Aug 17, 2026
e40a6fc
feat(webapp): thin scrollbars and inline empty objects in the sample …
ericallam Aug 17, 2026
296dd0f
fix(webapp): stop the smart-column sample showing 'No runs' while loa…
ericallam Aug 17, 2026
af5d330
feat(webapp): sample from any run that has an inline value for the so…
ericallam Aug 17, 2026
9782726
feat(webapp): default smart-column label to the array key, not the index
ericallam Aug 17, 2026
7059969
feat(webapp): show a full multi-row column preview beside the sample
ericallam Aug 17, 2026
14bda31
feat(webapp): support .length in smart-column JSON paths
ericallam Aug 17, 2026
08734eb
polish(webapp): tighten smart-column dialog copy and display popover …
ericallam Aug 17, 2026
20ca1c5
fix(webapp): address PR review on runs-list column customization
ericallam Aug 17, 2026
ed7c928
feat(webapp): move Display control next to pagination and add it to t…
ericallam Aug 18, 2026
c5756f5
feat(webapp): label the runs columns control "Columns"
ericallam Aug 18, 2026
07bf4bd
fix(webapp): preserve columns on clear-filters and read escaped sampl…
ericallam Aug 18, 2026
dfe36b4
perf(webapp): stop hydrating run metadata on the list unless a column…
ericallam Aug 18, 2026
466aff4
fix(webapp): drag-drop index, length-key sample path, live-poll paylo…
ericallam Aug 18, 2026
349a36c
fix(webapp): scope smart-column sample, suppress columns in embedded …
ericallam Aug 18, 2026
909c795
fix(webapp): don't coerce empty smart-column values to 0 in number/du…
ericallam Aug 18, 2026
df0cd8b
chore(webapp): drop unused exports flagged by knip
ericallam Aug 18, 2026
4fda2b8
chore(webapp): un-export the remaining knip-flagged runColumns symbols
ericallam Aug 18, 2026
c2384e2
perf(webapp): sample only the selected source in the Add smart column…
ericallam Aug 18, 2026
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
6 changes: 6 additions & 0 deletions .server-changes/runs-list-column-customization.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---

Customize the runs list: show, hide, and reorder columns, and add smart columns that pull a value straight out of a run's payload, metadata, or output. Your column choices are saved in the page URL so you can share or bookmark a view.
362 changes: 362 additions & 0 deletions apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,362 @@
import { BoltIcon, ChevronLeftIcon, ChevronRightIcon } from "@heroicons/react/20/solid";
import { useEffect, useMemo, useState } from "react";
import { useTypedFetcher } from "remix-typedjson";
import { Button } from "~/components/primitives/Buttons";
import { Callout } from "~/components/primitives/Callout";
import { Dialog, DialogContent, DialogHeader } from "~/components/primitives/Dialog";
import { Input } from "~/components/primitives/Input";
import { Label } from "~/components/primitives/Label";
import { Paragraph } from "~/components/primitives/Paragraph";
import { useEnvironment } from "~/hooks/useEnvironment";
import { useOrganization } from "~/hooks/useOrganizations";
import { useProject } from "~/hooks/useProject";
import { cn } from "~/utils/cn";
import {
SMART_COLUMN_DISPLAYS,
type SmartColumnDef,
type SmartColumnDisplay,
type SmartColumnSource,
} from "./runColumns";
import { extractSmartValue, labelFromPath, parseSource } from "./smartColumnData";
import { SmartColumnSample } from "./SmartColumnSample";
import type { loader as sampleLoader } from "~/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample";

type AddSmartColumnDialogProps = {
open: boolean;
/** When set, the dialog edits this existing column instead of adding a new one. */
editing: SmartColumnDef | null;
onOpenChange: (open: boolean) => void;
onSubmit: (def: SmartColumnDef) => void;
currentSearch: string;
};

const SOURCE_CARDS: { value: SmartColumnSource; label: string; description: string }[] = [
{ value: "payload", label: "Payload", description: "What you triggered the run with." },
{ value: "metadata", label: "Metadata", description: "What the run writes while it runs." },
{ value: "output", label: "Output", description: "What the run returned." },
];

const DISPLAY_OPTIONS = SMART_COLUMN_DISPLAYS.map((display) => ({
label: display.charAt(0).toUpperCase() + display.slice(1),
value: display,
}));

const DEFAULT_SOURCE: SmartColumnSource = "payload";

export function AddSmartColumnDialog({
open,
editing,
onOpenChange,
onSubmit,
currentSearch,
}: AddSmartColumnDialogProps) {
const organization = useOrganization();
const project = useProject();
const environment = useEnvironment();
const sample = useTypedFetcher<typeof sampleLoader>();

const [source, setSource] = useState<SmartColumnSource>(DEFAULT_SOURCE);
const [path, setPath] = useState("");
const [label, setLabel] = useState("");
const [labelEdited, setLabelEdited] = useState(false);
const [displayAs, setDisplayAs] = useState<SmartColumnDisplay>("text");
const [sampleIndex, setSampleIndex] = useState(0);

useEffect(() => {
if (!open) return;
setSource(editing?.source ?? DEFAULT_SOURCE);
setPath(editing?.path ?? "");
setLabel(editing?.label ?? "");
setLabelEdited(editing !== null);
setDisplayAs(editing?.displayAs ?? "text");
setSampleIndex(0);
}, [open, editing]);

const sampleUrl = useMemo(() => {
const base = `/resources/orgs/${organization.slug}/projects/${project.slug}/env/${environment.slug}/runs/smart-column-sample`;
return currentSearch ? `${base}?${currentSearch.replace(/^\?/, "")}` : base;
}, [organization.slug, project.slug, environment.slug, currentSearch]);

useEffect(() => {
if (open && sample.state === "idle" && sample.data === undefined) {
sample.load(sampleUrl);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, sampleUrl]);
Comment on lines +101 to +106

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Sample preview can get stuck on the wrong data when switching source quickly

The request for fresh sample data is skipped (sample.state === "idle" guard at apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx:102) whenever a previous request is still in flight, and it is never retried, so the preview can keep showing the old source's data indefinitely.
Impact: If someone flips between Payload/Metadata/Output while the previous sample is still loading, the sample and preview panes show nothing (or the wrong source) until they click a source again.

Effect dependency array cannot re-fire after the in-flight fetch settles

The effect's dependencies are [open, sampleUrl]. When the user changes source, sampleUrl changes and the effect runs, but if sample.state is "loading" at that moment the load() call is skipped. When the earlier request finishes and the fetcher returns to "idle", the dependencies have not changed, so the effect never re-runs and the new sampleUrl is never requested.

Because the sample loader only hydrates the requested source (runSelect: deriveRunSelect([], [source]) at apps/webapp/app/routes/resources.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs.smart-column-sample.ts:57), the retained data contains only the previous source's blob. perRun (apps/webapp/app/components/runs/v3/AddSmartColumnDialog.tsx:125-145) then parses run.output/run.metadata as undefined, so both the sample tree and the preview column render the "no source to sample" empty state.

The guard is also unnecessary for preventing duplicate loads: the effect already only fires when open or sampleUrl change.

Suggested change
useEffect(() => {
if (open && sample.state === "idle") {
sample.load(sampleUrl);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, sampleUrl]);
useEffect(() => {
if (!open) return;
sample.load(sampleUrl);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open, sampleUrl]);
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


const effectiveLabel = labelEdited ? label : labelFromPath(path);

const sampleRuns = sample.data?.runs ?? [];
const clampedIndex = sampleRuns.length > 0 ? Math.min(sampleIndex, sampleRuns.length - 1) : 0;
const sampleRun = sampleRuns[clampedIndex] ?? null;

const parsed = useMemo(() => {
if (!sampleRun) return undefined;
switch (source) {
case "payload":
return parseSource({ data: sampleRun.payload, dataType: sampleRun.payloadType });
case "metadata":
return parseSource({ data: sampleRun.metadata, dataType: sampleRun.metadataType });
case "output":
return parseSource({ data: sampleRun.output, dataType: sampleRun.outputType });
}
}, [sampleRun, source]);

const resolved = useMemo(() => {
if (!parsed || path.trim().length === 0) return undefined;
return extractSmartValue(parsed, path);
}, [parsed, path]);

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

const handleSubmit = () => {
if (!canSubmit) return;
onSubmit({ source, path: path.trim(), label: effectiveLabel.trim() || path.trim(), displayAs });
onOpenChange(false);
};

return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[820px]!">
<DialogHeader>{editing ? "Edit smart column" : "Add smart column"}</DialogHeader>
<div className="flex flex-col gap-5 p-1">
<Callout variant="info">
Display only. A smart column shows you a value from a run, but you can't sort or filter
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="flex flex-col gap-5">
<div className="flex flex-col gap-1.5">
<Label>Source</Label>
<div className="grid grid-cols-3 gap-2">
{SOURCE_CARDS.map((card) => (
<SourceCard
key={card.value}
label={card.label}
description={card.description}
selected={source === card.value}
onSelect={() => setSource(card.value)}
/>
))}
</div>
</div>

<div className="grid grid-cols-2 gap-4">
<div className="flex flex-col gap-1.5">
<Label>JSON path</Label>
<Input
value={path}
onChange={(e) => setPath(e.target.value)}
placeholder="$.order.total"
spellCheck={false}
/>
<Paragraph variant="extra-small" className="text-text-dimmed">
Dot and bracket notation, e.g. <code>$.order.total</code> or{" "}
<code>$.items[0].sku</code>.
</Paragraph>
</div>
<div className="flex flex-col gap-1.5">
<Label>Column label</Label>
<Input
value={effectiveLabel}
onChange={(e) => {
setLabel(e.target.value);
setLabelEdited(true);
}}
placeholder={labelFromPath(path)}
/>
<Paragraph variant="extra-small" className="text-text-dimmed">
Defaults to the last part of the path.
</Paragraph>
</div>
</div>

<div className="flex flex-col gap-1.5">
<Label>Display as</Label>
<div className="flex flex-wrap gap-2">
{DISPLAY_OPTIONS.map((option) => (
<button
key={option.value}
type="button"
onClick={() => setDisplayAs(option.value)}
className={cn(
"rounded-full border px-3.5 py-1 text-sm transition",
displayAs === option.value
? "border-blue-500 bg-blue-500/10 text-text-bright"
: "border-grid-bright text-text-dimmed hover:text-text-bright"
)}
>
{option.label}
</button>
))}
</div>
<Paragraph variant="extra-small" className="text-text-dimmed">
Number right-aligns the column and uses tabular figures. Anything that doesn't
parse falls back to text.
</Paragraph>
</div>
</div>

<div className="flex flex-col gap-1.5 self-start rounded-lg border border-grid-dimmed bg-background-dimmed p-3">
<div className="flex items-center justify-between gap-2">
<Paragraph variant="extra-extra-small/dimmed/caps">Sample — {source}</Paragraph>
{sampleRuns.length > 0 && (
<SampleRunPicker
index={clampedIndex}
total={sampleRuns.length}
onPrev={() => setSampleIndex((i) => Math.max(0, i - 1))}
onNext={() => setSampleIndex((i) => Math.min(sampleRuns.length - 1, i + 1))}
/>
)}
</div>
{sample.state === "loading" ? (
<Paragraph variant="extra-small" className="text-text-dimmed">
Loading…
</Paragraph>
) : !parsed ? (
<Paragraph variant="extra-small" className="text-text-dimmed">
No runs to sample.
</Paragraph>
) : parsed.state === "offloaded" ? (
<Paragraph variant="extra-small" className="text-text-dimmed">
This {source} is offloaded to object storage, too large to sample here.
</Paragraph>
) : parsed.state === "empty" ? (
<Paragraph variant="extra-small" className="text-text-dimmed">
No {source} value for this run.
</Paragraph>
) : (
<>
<SmartColumnSample
value={parsed.value}
activePath={path.trim()}
onSelectPath={setPath}
/>
<Paragraph variant="extra-small" className="text-text-dimmed">
Click a value to use its path. Expand objects and arrays to reach the value you
want.
</Paragraph>
</>
)}
<Paragraph variant="extra-extra-small/dimmed/caps" className="mt-2">
Resolves to
</Paragraph>
<SmartColumnResolvedPreview label={effectiveLabel} resolved={resolved} />
</div>
</div>
</div>
<div className="flex items-center justify-end gap-2 border-t border-grid-dimmed p-3">
<Button variant="tertiary/medium" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button variant="primary/medium" disabled={!canSubmit} onClick={handleSubmit}>
{editing ? "Save changes" : "Add column"}
</Button>
</div>
</DialogContent>
</Dialog>
);
}

function SampleRunPicker({
index,
total,
onPrev,
onNext,
}: {
index: number;
total: number;
onPrev: () => void;
onNext: () => void;
}) {
return (
<div className="flex flex-none items-center gap-1 text-xs text-text-dimmed">
<span className="tabular-nums">
{index + 1}/{total}
</span>
<button
type="button"
onClick={onPrev}
disabled={index === 0}
aria-label="Newer run"
className="flex size-5 items-center justify-center rounded hover:bg-charcoal-750 disabled:opacity-30"
>
<ChevronLeftIcon className="size-4" />
</button>
<button
type="button"
onClick={onNext}
disabled={index >= total - 1}
aria-label="Older run"
className="flex size-5 items-center justify-center rounded hover:bg-charcoal-750 disabled:opacity-30"
>
<ChevronRightIcon className="size-4" />
</button>
</div>
);
}

function SourceCard({
label,
description,
selected,
onSelect,
}: {
label: string;
description: string;
selected: boolean;
onSelect: () => void;
}) {
return (
<button
type="button"
onClick={onSelect}
aria-pressed={selected}
className={cn(
"flex flex-col gap-1 rounded-lg border p-2.5 text-left transition",
selected
? "border-blue-500 bg-blue-500/10"
: "border-grid-bright bg-background-dimmed hover:border-text-dimmed"
)}
>
<span className="flex items-center gap-1.5 text-sm font-medium text-text-bright">
<span
className={cn(
"grid size-3.5 flex-none place-items-center rounded-full border",
selected ? "border-blue-500" : "border-text-dimmed"
)}
>
{selected && <span className="size-1.5 rounded-full bg-blue-500" />}
</span>
{label}
</span>
<span className="text-xs text-text-dimmed">{description}</span>
</button>
);
}

function SmartColumnResolvedPreview({
label,
resolved,
}: {
label: string;
resolved: ReturnType<typeof extractSmartValue> | undefined;
}) {
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);

return (
<div className="rounded border border-grid-dimmed">
<div className="flex items-center gap-1 border-b border-grid-dimmed px-2 py-1">
<BoltIcon className="size-3.5 flex-none text-text-dimmed" />
<span className="truncate text-xs text-text-bright">{label || "Column"}</span>
</div>
<div className="px-2 py-1.5 text-right text-sm tabular-nums text-text-bright">{value}</div>
</div>
);
}
2 changes: 2 additions & 0 deletions apps/webapp/app/components/runs/v3/RunFilters.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ import { type loader as versionsLoader } from "~/routes/resources.orgs.$organiza
import { makeFriendlyIdValidator } from "~/utils/friendlyId";
import { Button } from "../../primitives/Buttons";
import { AIFilterInput } from "./AIFilterInput";
import { RunsDisplayOptions } from "./RunsDisplayOptions";
import { BulkActionTypeCombo } from "./BulkAction";
import { RegionLabel } from "./RegionLabel";
import {
Expand Down Expand Up @@ -415,6 +416,7 @@ export function RunsFilters(props: RunFiltersProps) {
/>
</Form>
)}
<RunsDisplayOptions />
Comment thread
ericallam marked this conversation as resolved.
Outdated
</div>
);
}
Expand Down
Loading
Loading