Skip to content

Commit 32bf745

Browse files
ericallamsamejrclaude
authored
feat(webapp): customizable runs list with columns and smart columns (#4652)
## Summary Makes the runs list customizable. A new **Display** control lets you show, hide, and reorder columns, and add **smart columns** that pull a single value out of a run's payload, metadata, or output by JSON path (e.g. `$.failed`, `$.order.total`). Column choices live in the page URL, so a view can be bookmarked or shared. Applies to the global runs list and every per-task / scheduled / agent / webhook / error list, which all share one table. ID, Task, and Status can be reordered but not hidden. Smart columns are display-only (no sort or filter, which would defeat the ClickHouse sort key and cursor). ## How it works Columns come from a shared registry; the Postgres `select` is derived from the visible columns, so a run's large payload/output are only hydrated when a smart column actually references them. All JSON parsing for smart columns happens client-side, respecting the packet content type, parsed once per source per row. Offloaded (too-large) values and paths that aren't present render distinct placeholders rather than fetching per row. The live poll carries the same sources so smart-column values update in place. Scalar columns stay always-selected for now: the shared list presenter has a fixed output shape consumed by several routes and the live poll, and narrowing individual scalar fields would add no real query cost benefit on a single-row read. The select derivation is already column-driven, so tightening this later is a one-line change. ## Screenshots <img width="590" height="1028" alt="CleanShot 2026-08-21 at 16 48 17@2x" src="https://github.com/user-attachments/assets/86b39856-bfcc-47c0-85ed-ee6ccddc3590" /> <img width="1924" height="1528" alt="CleanShot 2026-08-21 at 16 48 27@2x" src="https://github.com/user-attachments/assets/6c766249-6d5b-45be-9330-c6caa75af7f7" /> <!-- conductor-workspace-link --> --- [Open workspace in Conductor](https://app.conductor.build/workspace/d6911080-2140-4de1-b88a-1b0623593caa) --------- Co-authored-by: James Ritchie <james@trigger.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent aa9b888 commit 32bf745

38 files changed

Lines changed: 3067 additions & 405 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: webapp
3+
type: feature
4+
---
5+
6+
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 a view, bookmark it, or save it straight to your favorites.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export function ColumnsIcon({ className }: { className?: string }) {
2+
return (
3+
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
4+
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" strokeWidth="2" />
5+
<line x1="9" y1="19" x2="9" y2="5" stroke="currentColor" strokeWidth="2" />
6+
<line x1="15" y1="19" x2="15" y2="5" stroke="currentColor" strokeWidth="2" />
7+
</svg>
8+
);
9+
}
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export function ResetIcon({ className }: { className?: string }) {
2+
return (
3+
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
4+
<path
5+
d="M7 3L4 6L7 9"
6+
stroke="currentColor"
7+
strokeWidth="2"
8+
strokeLinecap="round"
9+
strokeLinejoin="round"
10+
/>
11+
<path
12+
d="M5 6H13.5C17.0899 6 20 8.91015 20 12.5C20 16.0899 17.0899 19 13.5 19H6"
13+
stroke="currentColor"
14+
strokeWidth="2"
15+
strokeLinecap="round"
16+
strokeLinejoin="round"
17+
/>
18+
</svg>
19+
);
20+
}
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
/** Marks a smart column: in the runs table header, the Columns popover, and the dialog preview. */
2+
export function SmartColumnIcon({ className }: { className?: string }) {
3+
return (
4+
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
5+
<path
6+
d="M5.94723 12.4318L12.3011 3.53646C12.9468 2.63242 14.3689 3.24855 14.1511 4.33794L13.1543 9.32131C13.0905 9.64031 13.3346 9.93793 13.6599 9.93793H17.2138C18.0524 9.93793 18.5402 10.8859 18.0527 11.5682L11.6989 20.4636C11.0532 21.3676 9.63107 20.7515 9.84895 19.6621L10.8456 14.6788C10.9095 14.3598 10.6654 14.0621 10.3401 14.0621H6.78622C5.9476 14.0621 5.45978 13.1142 5.94723 12.4318Z"
7+
stroke="currentColor"
8+
strokeWidth="2"
9+
strokeLinejoin="round"
10+
/>
11+
</svg>
12+
);
13+
}

apps/webapp/app/components/Shortcuts.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { ASK_AI_SHORTCUT, askAiCanOpen } from "~/components/dashboard-agent/ask-
66
import { useDashboardAgentAvailable } from "~/components/dashboard-agent/dashboardAgentOpenRequest";
77
import { NEW_CHAT_SHORTCUT } from "~/components/dashboard-agent/DashboardAgentHeader";
88
import { TOGGLE_PANEL_SHORTCUT } from "~/components/dashboard-agent/dashboardAgentLauncher";
9+
import { COLUMNS_SHORTCUT } from "~/components/runs/v3/RunsDisplayOptions";
910
import { useAskAiAvailability } from "~/hooks/useAskAiAvailability";
1011
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
1112
import { Header3 } from "./primitives/Headers";
@@ -142,6 +143,9 @@ function ShortcutContent() {
142143
)}
143144
<div className="space-y-3">
144145
<Header3>Runs page</Header3>
146+
<Shortcut name="Customize columns">
147+
<ShortcutKey shortcut={COLUMNS_SHORTCUT} variant="medium/bright" />
148+
</Shortcut>
145149
<Shortcut name="Bulk action: Cancel runs">
146150
<ShortcutKey shortcut={{ key: "c" }} variant="medium/bright" />
147151
</Shortcut>

apps/webapp/app/components/navigation/FavoritePageButton.tsx

Lines changed: 4 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,14 @@
11
import { StarIcon as StarIconOutline } from "@heroicons/react/24/outline";
22
import { StarIcon as StarIconSolid } from "@heroicons/react/20/solid";
3-
import { useFetcher, useLocation, useSearchParams } from "@remix-run/react";
3+
import { useLocation, useSearchParams } from "@remix-run/react";
44
import { useEffect } from "react";
5-
import { useIsImpersonating } from "~/hooks/useOrganizations";
65
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
76
import { useOptionalUser } from "~/hooks/useUser";
87
import { cn } from "~/utils/cn";
98
import { Button } from "../primitives/Buttons";
109
import { ShortcutKey } from "../primitives/ShortcutKey";
1110
import { SimpleTooltip } from "../primitives/Tooltip";
12-
import {
13-
buildFavoriteLabel,
14-
canonicalFavoriteUrl,
15-
FAVORITE_SEARCH_PARAM,
16-
FAVORITES_ACTION_PATH,
17-
favoritePageUrl,
18-
resolvePageMeta,
19-
useFavorites,
20-
} from "./favoritePages";
11+
import { FAVORITE_SEARCH_PARAM, useFavoritePageToggle, useFavorites } from "./favoritePages";
2112

2213
/**
2314
* The star in the page header that favorites the current page (full URL, including filters and
@@ -31,15 +22,10 @@ export function FavoritePageButton({
3122
className?: string;
3223
}) {
3324
const user = useOptionalUser();
34-
const isImpersonating = useIsImpersonating();
3525
const location = useLocation();
3626
const favorites = useFavorites();
37-
const fetcher = useFetcher();
3827
const [, setSearchParams] = useSearchParams();
39-
40-
// The marker param and pagination position never count toward URL identity, so paging through
41-
// a favorited view keeps the same favorite (and never saves a soon-stale cursor)
42-
const url = favoritePageUrl(location.pathname, location.search);
28+
const { isFavorited, pageName, canFavorite, toggle } = useFavoritePageToggle(pageTitle);
4329

4430
// A marker that isn't one of this user's favorites came from a shared link (or a favorite
4531
// that's since been removed): clean it from the URL so the page behaves like a normal visit.
@@ -58,34 +44,8 @@ export function FavoritePageButton({
5844
{ replace: true, preventScrollReset: true }
5945
);
6046
}, [hasForeignMarker, setSearchParams]);
61-
const existing = favorites.find((favorite) => canonicalFavoriteUrl(favorite.url) === url);
62-
const isFavorited = existing !== undefined;
63-
// The tooltip names the favorite: its custom name once saved, else the label saving would use
64-
// (which includes detail-page ids and filter summaries, e.g. "Runs: Completed, last 7d")
65-
const pageName =
66-
existing?.label ?? buildFavoriteLabel(location.pathname, location.search, pageTitle);
67-
68-
const toggle = () => {
69-
if (existing) {
70-
fetcher.submit(
71-
{ intent: "remove", id: existing.id },
72-
{ method: "POST", action: FAVORITES_ACTION_PATH }
73-
);
74-
} else {
75-
fetcher.submit(
76-
{
77-
intent: "add",
78-
id: crypto.randomUUID(),
79-
url,
80-
label: buildFavoriteLabel(location.pathname, location.search, pageTitle),
81-
icon: resolvePageMeta(location.pathname).icon,
82-
},
83-
{ method: "POST", action: FAVORITES_ACTION_PATH }
84-
);
85-
}
86-
};
8747

88-
const showButton = user !== undefined && !isImpersonating;
48+
const showButton = canFavorite;
8949

9050
// Option+F reports event.key "ƒ" on macOS, but the hotkeys matcher falls back to the physical
9151
// event.code ("KeyF"), so the standard hook captures it; exact modifier matching keeps the

apps/webapp/app/components/navigation/favoritePages.tsx

Lines changed: 65 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { BeakerIcon } from "@heroicons/react/24/outline";
22
import { IconChartHistogram } from "@tabler/icons-react";
3-
import { useFetchers, useLocation } from "@remix-run/react";
3+
import { useFetcher, useFetchers, useLocation } from "@remix-run/react";
44
import { ClockIcon } from "~/assets/icons/ClockIcon";
55
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
66
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
@@ -42,8 +42,10 @@ import { UserGroupIcon } from "~/assets/icons/UserGroupIcon";
4242
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
4343
import { WebhookIcon } from "~/assets/icons/WebhookIcon";
4444
import { VercelLogo } from "~/components/integrations/VercelLogo";
45+
import { useIsImpersonating } from "~/hooks/useOrganizations";
4546
import { useOptionalUser } from "~/hooks/useUser";
4647
import { type FavoritePage } from "~/services/dashboardPreferences.server";
48+
import { RUN_COLUMN_SEARCH_PARAMS } from "../runs/v3/runColumns";
4749
import { type RenderIcon } from "../primitives/Icon";
4850

4951
export const FAVORITES_ACTION_PATH = "/resources/preferences/favorites";
@@ -144,7 +146,7 @@ const PAGINATION_PARAMS = ["cursor", "direction", "page"];
144146
* marker (presentation-only) and the pagination position (cursors go stale, and page N of a view
145147
* is not a different view). A favorite pins filters and tabs, never a transient page of them.
146148
*/
147-
export function favoritePageUrl(pathname: string, search: string): string {
149+
function favoritePageUrl(pathname: string, search: string): string {
148150
const params = new URLSearchParams(search);
149151
params.delete(FAVORITE_SEARCH_PARAM);
150152
for (const param of PAGINATION_PARAMS) {
@@ -156,7 +158,7 @@ export function favoritePageUrl(pathname: string, search: string): string {
156158

157159
/** favoritePageUrl for an already-joined URL, e.g. a favorite's stored one (which may predate
158160
* pagination stripping). */
159-
export function canonicalFavoriteUrl(url: string): string {
161+
function canonicalFavoriteUrl(url: string): string {
160162
const [pathname, search = ""] = url.split("?");
161163
return favoritePageUrl(pathname, search);
162164
}
@@ -260,7 +262,7 @@ const ACCOUNT_PAGE_META: Record<string, PageMeta> = {
260262
};
261263

262264
/** Best-effort icon + name for any dashboard page, derived from its URL shape. */
263-
export function resolvePageMeta(pathname: string): PageMeta {
265+
function resolvePageMeta(pathname: string): PageMeta {
264266
const envMatch = pathname.match(/^\/orgs\/[^/]+\/projects\/[^/]+\/env\/[^/]+(?:\/([^?]*))?$/);
265267
if (envMatch) {
266268
const segments = (envMatch[1] ?? "").split("/").filter(Boolean);
@@ -365,7 +367,14 @@ function humanizeValue(value: string): string {
365367
}
366368

367369
/** Pagination/UI-state params that never describe what the user filtered. */
368-
const NON_FILTER_PARAMS = [FAVORITE_SEARCH_PARAM, ...PAGINATION_PARAMS, "span"];
370+
const NON_FILTER_PARAMS = [
371+
FAVORITE_SEARCH_PARAM,
372+
...PAGINATION_PARAMS,
373+
"span",
374+
// The runs list stores its column layout in the URL; that's presentation, not a filter,
375+
// so it must not count toward the tally ("Runs: 3 filters" for an unfiltered view).
376+
...RUN_COLUMN_SEARCH_PARAMS,
377+
];
369378

370379
/**
371380
* Summarize a filtered view's search params into a short, selective descriptor for the favorite
@@ -425,7 +434,7 @@ function describeFilters(search: string): string | undefined {
425434
* id for friendly-id pages: "Run: 05hrqq9n"); filtered views summarize their filters ("Runs:
426435
* Completed successfully, last 7d"). Users can always rename.
427436
*/
428-
export function buildFavoriteLabel(
437+
function buildFavoriteLabel(
429438
pathname: string,
430439
search: string,
431440
pageTitle: string | undefined
@@ -512,3 +521,53 @@ export function useFavorites(): FavoritePage[] {
512521

513522
return favorites;
514523
}
524+
525+
/**
526+
* Shared favorite state + toggle for the current page (full URL, including filters and tabs).
527+
* Backs both the page-header star and the runs list "Save to favorites" menu item, so the two
528+
* always agree on what counts as favorited and produce identical favorites.
529+
*/
530+
export function useFavoritePageToggle(pageTitle?: string): {
531+
isFavorited: boolean;
532+
/** The favorite's custom name once saved, else the label saving would use. */
533+
pageName: string;
534+
/** False for logged-out and impersonating sessions, which must not mutate preferences. */
535+
canFavorite: boolean;
536+
toggle: () => void;
537+
} {
538+
const user = useOptionalUser();
539+
const isImpersonating = useIsImpersonating();
540+
const location = useLocation();
541+
const favorites = useFavorites();
542+
const fetcher = useFetcher();
543+
544+
const url = favoritePageUrl(location.pathname, location.search);
545+
const existing = favorites.find((favorite) => canonicalFavoriteUrl(favorite.url) === url);
546+
547+
const toggle = () => {
548+
if (existing) {
549+
fetcher.submit(
550+
{ intent: "remove", id: existing.id },
551+
{ method: "POST", action: FAVORITES_ACTION_PATH }
552+
);
553+
} else {
554+
fetcher.submit(
555+
{
556+
intent: "add",
557+
id: crypto.randomUUID(),
558+
url,
559+
label: buildFavoriteLabel(location.pathname, location.search, pageTitle),
560+
icon: resolvePageMeta(location.pathname).icon,
561+
},
562+
{ method: "POST", action: FAVORITES_ACTION_PATH }
563+
);
564+
}
565+
};
566+
567+
return {
568+
isFavorited: existing !== undefined,
569+
pageName: existing?.label ?? buildFavoriteLabel(location.pathname, location.search, pageTitle),
570+
canFavorite: user !== undefined && !isImpersonating,
571+
toggle,
572+
};
573+
}

apps/webapp/app/components/primitives/MiddleTruncate.tsx

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,19 +5,44 @@ import { SimpleTooltip } from "./Tooltip";
55
type MiddleTruncateProps = {
66
text: string;
77
className?: string;
8+
/** Hover delay before the full-text tooltip opens. Defaults to the tooltip default (0). */
9+
tooltipDelay?: number;
10+
/** Merged onto the tooltip body, for callers whose text needs a bigger or scrollable box. */
11+
tooltipContentClassName?: string;
12+
/**
13+
* Roughly how many characters fit, used only for the very first render. Truncation needs
14+
* layout, so the server (and the pre-hydration client) can only render the full string --
15+
* long values visibly snapped shorter once React hydrated. Seeding from a character count
16+
* is deterministic, so it matches on both sides and the measured pass just refines it.
17+
*/
18+
initialCharBudget?: number;
819
};
920

21+
/** Deterministic, layout-free middle truncation used to seed the first render. */
22+
function seedTruncation(text: string, budget: number | undefined): string {
23+
if (budget === undefined || text.length <= budget) return text;
24+
const keep = Math.max(1, Math.floor((budget - 1) / 2));
25+
return `${text.slice(0, keep)}${text.slice(-keep)}`;
26+
}
27+
1028
/**
1129
* A component that truncates text in the middle, showing the beginning and end.
1230
* Shows the full text in a tooltip on hover when truncated.
1331
*
1432
* Example: "namespace:category:subcategory:task-name" becomes "namespace:cat…task-name"
1533
*/
16-
export function MiddleTruncate({ text, className }: MiddleTruncateProps) {
34+
export function MiddleTruncate({
35+
text,
36+
className,
37+
tooltipDelay,
38+
tooltipContentClassName,
39+
initialCharBudget,
40+
}: MiddleTruncateProps) {
41+
const seed = seedTruncation(text, initialCharBudget);
1742
const containerRef = useRef<HTMLSpanElement>(null);
1843
const measureRef = useRef<HTMLSpanElement>(null);
19-
const [displayText, setDisplayText] = useState(text);
20-
const [isTruncated, setIsTruncated] = useState(false);
44+
const [displayText, setDisplayText] = useState(seed);
45+
const [isTruncated, setIsTruncated] = useState(seed !== text);
2146

2247
const calculateTruncation = useCallback(() => {
2348
const container = containerRef.current;
@@ -151,9 +176,14 @@ export function MiddleTruncate({ text, className }: MiddleTruncateProps) {
151176
return (
152177
<SimpleTooltip
153178
button={content}
154-
content={<span className="max-w-xs break-all font-mono text-xs">{text}</span>}
179+
content={
180+
<span className={cn("max-w-xs break-all font-mono text-xs", tooltipContentClassName)}>
181+
{text}
182+
</span>
183+
}
155184
side="top"
156185
asChild
186+
delayDuration={tooltipDelay}
157187
/>
158188
);
159189
}

0 commit comments

Comments
 (0)