Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
49 commits
Select commit Hold shift + click to select a range
23b4f0b
feat(webapp): runs-list column registry, URL codec, and smart-column …
ericallam Aug 17, 2026
7d519c1
feat(webapp): derive the runs-list Postgres select from visible columns
ericallam Aug 17, 2026
a33890a
feat(webapp): column display options and smart columns on the runs list
ericallam Aug 17, 2026
a071987
feat(webapp): keep smart-column values fresh in the runs live poll
ericallam Aug 17, 2026
3e3d717
docs(webapp): add server-changes note for runs-list column customization
ericallam Aug 17, 2026
41c06a5
feat(webapp): editable smart columns and display-options polish
ericallam Aug 17, 2026
380a665
feat(webapp): stable column toggle and a clearer smart-column icon
ericallam Aug 17, 2026
ecd527a
feat(webapp): use a bolt icon for smart columns and reveal row action…
ericallam Aug 17, 2026
fbedcb9
feat(webapp): compact column URL state
ericallam Aug 17, 2026
7d07612
feat(webapp): redesign the add-smart-column modal
ericallam Aug 17, 2026
c2ad8be
feat(webapp): syntax-highlight the smart-column sample and cap large …
ericallam Aug 17, 2026
a4746c6
feat(webapp): click a key in the smart-column sample to fill the path
ericallam Aug 17, 2026
7f776d7
feat(webapp): only leaf values are selectable in the sample tree
ericallam Aug 17, 2026
de33c52
feat(webapp): expand the sample tree and let it page through recent runs
ericallam Aug 17, 2026
d45f094
feat(webapp): trim smart-column sample chrome
ericallam Aug 17, 2026
bb2319a
feat(webapp): thin scrollbars and inline empty objects in the sample …
ericallam Aug 17, 2026
865a4e7
fix(webapp): stop the smart-column sample showing 'No runs' while loa…
ericallam Aug 17, 2026
ddd4a93
feat(webapp): sample from any run that has an inline value for the so…
ericallam Aug 17, 2026
5d83356
feat(webapp): default smart-column label to the array key, not the index
ericallam Aug 17, 2026
522c8cd
feat(webapp): show a full multi-row column preview beside the sample
ericallam Aug 17, 2026
bd6488f
feat(webapp): support .length in smart-column JSON paths
ericallam Aug 17, 2026
8bd5f8c
polish(webapp): tighten smart-column dialog copy and display popover …
ericallam Aug 17, 2026
1517eb2
fix(webapp): address PR review on runs-list column customization
ericallam Aug 17, 2026
2d3140f
feat(webapp): move Display control next to pagination and add it to t…
ericallam Aug 18, 2026
f913175
feat(webapp): label the runs columns control "Columns"
ericallam Aug 18, 2026
054e39b
fix(webapp): preserve columns on clear-filters and read escaped sampl…
ericallam Aug 18, 2026
63eb26e
perf(webapp): stop hydrating run metadata on the list unless a column…
ericallam Aug 18, 2026
58b2826
fix(webapp): drag-drop index, length-key sample path, live-poll paylo…
ericallam Aug 18, 2026
b996490
fix(webapp): scope smart-column sample, suppress columns in embedded …
ericallam Aug 18, 2026
893cd8f
fix(webapp): don't coerce empty smart-column values to 0 in number/du…
ericallam Aug 18, 2026
519dcaf
chore(webapp): drop unused exports flagged by knip
ericallam Aug 18, 2026
b9ed416
chore(webapp): un-export the remaining knip-flagged runColumns symbols
ericallam Aug 18, 2026
1e2b056
perf(webapp): sample only the selected source in the Add smart column…
ericallam Aug 18, 2026
61cba49
fix(webapp): always reload the smart-column sample when the source ch…
ericallam Aug 19, 2026
3c8e4eb
fix(webapp): forward region and task-type filters to the smart-column…
ericallam Aug 19, 2026
ec908e4
Merge branch 'main' into feature/tri-13328-runs-list-column-customiza…
samejr Aug 21, 2026
e12b622
feat(webapp): polish the runs list Columns popover
samejr Aug 21, 2026
0c37f67
feat(webapp): add a Columns shortcut and match filter popover metrics
samejr Aug 21, 2026
aa81fda
feat(webapp): clearer disabled Reset, keyboard focus, new Columns icon
samejr Aug 21, 2026
95827ba
feat(webapp): restructure the smart column dialog into three even col…
samejr Aug 21, 2026
a8f55e4
feat(webapp): use the shared form primitives in the smart column dialog
samejr Aug 21, 2026
c4dca44
fix(webapp): use indigo for the column reorder drop indicator
samejr Aug 21, 2026
b56742d
chore(webapp): drop the em-dash from the smart column dialog copy
samejr Aug 21, 2026
9da734f
feat(webapp): truncate long smart column values and polish the header
samejr Aug 21, 2026
999aadf
feat(webapp): put the smart column tooltip on the bolt, fix light mod…
samejr Aug 21, 2026
7d21b92
fix(webapp): stop smart column text re-truncating and the dialog growing
samejr Aug 21, 2026
5c7e8c7
fix(webapp): move the column row focus ring to its hit area
samejr Aug 21, 2026
8f09a9d
Merge remote-tracking branch 'origin/main' into feature/tri-13328-run…
samejr Aug 21, 2026
ae6aac8
fix(webapp): don't count the column layout as filters in favorite names
samejr Aug 21, 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 a view, bookmark it, or save it straight to your favorites.
9 changes: 9 additions & 0 deletions apps/webapp/app/assets/icons/ColumnsIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export function ColumnsIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect x="3" y="4" width="18" height="16" rx="2" stroke="currentColor" strokeWidth="2" />
<line x1="9" y1="19" x2="9" y2="5" stroke="currentColor" strokeWidth="2" />
<line x1="15" y1="19" x2="15" y2="5" stroke="currentColor" strokeWidth="2" />
</svg>
);
}
20 changes: 20 additions & 0 deletions apps/webapp/app/assets/icons/ResetIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
export function ResetIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M7 3L4 6L7 9"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M5 6H13.5C17.0899 6 20 8.91015 20 12.5C20 16.0899 17.0899 19 13.5 19H6"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
13 changes: 13 additions & 0 deletions apps/webapp/app/assets/icons/SmartColumnIcon.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/** Marks a smart column: in the runs table header, the Columns popover, and the dialog preview. */
export function SmartColumnIcon({ className }: { className?: string }) {
return (
<svg className={className} viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
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"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="round"
/>
</svg>
);
}
4 changes: 4 additions & 0 deletions apps/webapp/app/components/Shortcuts.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { ASK_AI_SHORTCUT, askAiCanOpen } from "~/components/dashboard-agent/ask-
import { useDashboardAgentAvailable } from "~/components/dashboard-agent/dashboardAgentOpenRequest";
import { NEW_CHAT_SHORTCUT } from "~/components/dashboard-agent/DashboardAgentHeader";
import { TOGGLE_PANEL_SHORTCUT } from "~/components/dashboard-agent/dashboardAgentLauncher";
import { COLUMNS_SHORTCUT } from "~/components/runs/v3/RunsDisplayOptions";
import { useAskAiAvailability } from "~/hooks/useAskAiAvailability";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { Header3 } from "./primitives/Headers";
Expand Down Expand Up @@ -142,6 +143,9 @@ function ShortcutContent() {
)}
<div className="space-y-3">
<Header3>Runs page</Header3>
<Shortcut name="Customize columns">
<ShortcutKey shortcut={COLUMNS_SHORTCUT} variant="medium/bright" />
</Shortcut>
<Shortcut name="Bulk action: Cancel runs">
<ShortcutKey shortcut={{ key: "c" }} variant="medium/bright" />
</Shortcut>
Expand Down
48 changes: 4 additions & 44 deletions apps/webapp/app/components/navigation/FavoritePageButton.tsx
Original file line number Diff line number Diff line change
@@ -1,23 +1,14 @@
import { StarIcon as StarIconOutline } from "@heroicons/react/24/outline";
import { StarIcon as StarIconSolid } from "@heroicons/react/20/solid";
import { useFetcher, useLocation, useSearchParams } from "@remix-run/react";
import { useLocation, useSearchParams } from "@remix-run/react";
import { useEffect } from "react";
import { useIsImpersonating } from "~/hooks/useOrganizations";
import { useShortcutKeys } from "~/hooks/useShortcutKeys";
import { useOptionalUser } from "~/hooks/useUser";
import { cn } from "~/utils/cn";
import { Button } from "../primitives/Buttons";
import { ShortcutKey } from "../primitives/ShortcutKey";
import { SimpleTooltip } from "../primitives/Tooltip";
import {
buildFavoriteLabel,
canonicalFavoriteUrl,
FAVORITE_SEARCH_PARAM,
FAVORITES_ACTION_PATH,
favoritePageUrl,
resolvePageMeta,
useFavorites,
} from "./favoritePages";
import { FAVORITE_SEARCH_PARAM, useFavoritePageToggle, useFavorites } from "./favoritePages";

/**
* The star in the page header that favorites the current page (full URL, including filters and
Expand All @@ -31,15 +22,10 @@ export function FavoritePageButton({
className?: string;
}) {
const user = useOptionalUser();
const isImpersonating = useIsImpersonating();
const location = useLocation();
const favorites = useFavorites();
const fetcher = useFetcher();
const [, setSearchParams] = useSearchParams();

// The marker param and pagination position never count toward URL identity, so paging through
// a favorited view keeps the same favorite (and never saves a soon-stale cursor)
const url = favoritePageUrl(location.pathname, location.search);
const { isFavorited, pageName, canFavorite, toggle } = useFavoritePageToggle(pageTitle);

// A marker that isn't one of this user's favorites came from a shared link (or a favorite
// that's since been removed): clean it from the URL so the page behaves like a normal visit.
Expand All @@ -58,34 +44,8 @@ export function FavoritePageButton({
{ replace: true, preventScrollReset: true }
);
}, [hasForeignMarker, setSearchParams]);
const existing = favorites.find((favorite) => canonicalFavoriteUrl(favorite.url) === url);
const isFavorited = existing !== undefined;
// The tooltip names the favorite: its custom name once saved, else the label saving would use
// (which includes detail-page ids and filter summaries, e.g. "Runs: Completed, last 7d")
const pageName =
existing?.label ?? buildFavoriteLabel(location.pathname, location.search, pageTitle);

const toggle = () => {
if (existing) {
fetcher.submit(
{ intent: "remove", id: existing.id },
{ method: "POST", action: FAVORITES_ACTION_PATH }
);
} else {
fetcher.submit(
{
intent: "add",
id: crypto.randomUUID(),
url,
label: buildFavoriteLabel(location.pathname, location.search, pageTitle),
icon: resolvePageMeta(location.pathname).icon,
},
{ method: "POST", action: FAVORITES_ACTION_PATH }
);
}
};

const showButton = user !== undefined && !isImpersonating;
const showButton = canFavorite;

// Option+F reports event.key "ƒ" on macOS, but the hotkeys matcher falls back to the physical
// event.code ("KeyF"), so the standard hook captures it; exact modifier matching keeps the
Expand Down
71 changes: 65 additions & 6 deletions apps/webapp/app/components/navigation/favoritePages.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { BeakerIcon } from "@heroicons/react/24/outline";
import { IconChartHistogram } from "@tabler/icons-react";
import { useFetchers, useLocation } from "@remix-run/react";
import { useFetcher, useFetchers, useLocation } from "@remix-run/react";
import { ClockIcon } from "~/assets/icons/ClockIcon";
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
Expand Down Expand Up @@ -42,8 +42,10 @@ import { UserGroupIcon } from "~/assets/icons/UserGroupIcon";
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
import { WebhookIcon } from "~/assets/icons/WebhookIcon";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { useIsImpersonating } from "~/hooks/useOrganizations";
import { useOptionalUser } from "~/hooks/useUser";
import { type FavoritePage } from "~/services/dashboardPreferences.server";
import { RUN_COLUMN_SEARCH_PARAMS } from "../runs/v3/runColumns";
import { type RenderIcon } from "../primitives/Icon";

export const FAVORITES_ACTION_PATH = "/resources/preferences/favorites";
Expand Down Expand Up @@ -144,7 +146,7 @@ const PAGINATION_PARAMS = ["cursor", "direction", "page"];
* marker (presentation-only) and the pagination position (cursors go stale, and page N of a view
* is not a different view). A favorite pins filters and tabs, never a transient page of them.
*/
export function favoritePageUrl(pathname: string, search: string): string {
function favoritePageUrl(pathname: string, search: string): string {
const params = new URLSearchParams(search);
params.delete(FAVORITE_SEARCH_PARAM);
for (const param of PAGINATION_PARAMS) {
Comment thread
samejr marked this conversation as resolved.
Expand All @@ -156,7 +158,7 @@ export function favoritePageUrl(pathname: string, search: string): string {

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

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

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

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

return favorites;
}

/**
* Shared favorite state + toggle for the current page (full URL, including filters and tabs).
* Backs both the page-header star and the runs list "Save to favorites" menu item, so the two
* always agree on what counts as favorited and produce identical favorites.
*/
export function useFavoritePageToggle(pageTitle?: string): {
isFavorited: boolean;
/** The favorite's custom name once saved, else the label saving would use. */
pageName: string;
/** False for logged-out and impersonating sessions, which must not mutate preferences. */
canFavorite: boolean;
toggle: () => void;
} {
const user = useOptionalUser();
const isImpersonating = useIsImpersonating();
const location = useLocation();
const favorites = useFavorites();
const fetcher = useFetcher();

const url = favoritePageUrl(location.pathname, location.search);
const existing = favorites.find((favorite) => canonicalFavoriteUrl(favorite.url) === url);

const toggle = () => {
if (existing) {
fetcher.submit(
{ intent: "remove", id: existing.id },
{ method: "POST", action: FAVORITES_ACTION_PATH }
);
} else {
fetcher.submit(
{
intent: "add",
id: crypto.randomUUID(),
url,
label: buildFavoriteLabel(location.pathname, location.search, pageTitle),
Comment thread
samejr marked this conversation as resolved.
icon: resolvePageMeta(location.pathname).icon,
},
{ method: "POST", action: FAVORITES_ACTION_PATH }
);
}
};

return {
isFavorited: existing !== undefined,
pageName: existing?.label ?? buildFavoriteLabel(location.pathname, location.search, pageTitle),
canFavorite: user !== undefined && !isImpersonating,
toggle,
};
}
38 changes: 34 additions & 4 deletions apps/webapp/app/components/primitives/MiddleTruncate.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,44 @@ import { SimpleTooltip } from "./Tooltip";
type MiddleTruncateProps = {
text: string;
className?: string;
/** Hover delay before the full-text tooltip opens. Defaults to the tooltip default (0). */
tooltipDelay?: number;
/** Merged onto the tooltip body, for callers whose text needs a bigger or scrollable box. */
tooltipContentClassName?: string;
/**
* Roughly how many characters fit, used only for the very first render. Truncation needs
* layout, so the server (and the pre-hydration client) can only render the full string --
* long values visibly snapped shorter once React hydrated. Seeding from a character count
* is deterministic, so it matches on both sides and the measured pass just refines it.
*/
initialCharBudget?: number;
};

/** Deterministic, layout-free middle truncation used to seed the first render. */
function seedTruncation(text: string, budget: number | undefined): string {
if (budget === undefined || text.length <= budget) return text;
const keep = Math.max(1, Math.floor((budget - 1) / 2));
return `${text.slice(0, keep)}…${text.slice(-keep)}`;
}

/**
* A component that truncates text in the middle, showing the beginning and end.
* Shows the full text in a tooltip on hover when truncated.
*
* Example: "namespace:category:subcategory:task-name" becomes "namespace:cat…task-name"
*/
export function MiddleTruncate({ text, className }: MiddleTruncateProps) {
export function MiddleTruncate({
text,
className,
tooltipDelay,
tooltipContentClassName,
initialCharBudget,
}: MiddleTruncateProps) {
const seed = seedTruncation(text, initialCharBudget);
const containerRef = useRef<HTMLSpanElement>(null);
const measureRef = useRef<HTMLSpanElement>(null);
const [displayText, setDisplayText] = useState(text);
const [isTruncated, setIsTruncated] = useState(false);
const [displayText, setDisplayText] = useState(seed);
const [isTruncated, setIsTruncated] = useState(seed !== text);

const calculateTruncation = useCallback(() => {
const container = containerRef.current;
Expand Down Expand Up @@ -151,9 +176,14 @@ export function MiddleTruncate({ text, className }: MiddleTruncateProps) {
return (
<SimpleTooltip
button={content}
content={<span className="max-w-xs break-all font-mono text-xs">{text}</span>}
content={
<span className={cn("max-w-xs break-all font-mono text-xs", tooltipContentClassName)}>
{text}
</span>
}
side="top"
asChild
delayDuration={tooltipDelay}
/>
);
}
Expand Down
Loading
Loading