Skip to content

Commit e12b622

Browse files
samejrclaude
andcommitted
feat(webapp): polish the runs list Columns popover
- Swap the trigger icon for a dedicated ColumnsIcon (three-column glyph). - Match the right-hand toolbar gap to the filters on the left (gap-x-1.5). - Style the column list's scrollbar with the app's standard thin scrollbar. - Replace the hand-rolled popover buttons with PopoverMenuItem, and pad the column list, so heights, padding, hover and cursor match menus elsewhere. The rows' name area is now a native label, so clicking it toggles the column and shows a pointer; locked rows stay non-interactive. - Stop Radix focusing the first row on open, which revealed that row's hover-only reorder handle through :focus-within before the mouse arrived. - Add "Save to favorites" / "Remove from favorites", sharing one useFavoritePageToggle hook with the page-header star so the two agree on what's favorited and produce identical favorites. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent ec908e4 commit e12b622

7 files changed

Lines changed: 136 additions & 79 deletions

File tree

.server-changes/runs-list-column-customization.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,4 @@ area: webapp
33
type: feature
44
---
55

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 or bookmark a view.
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="4" y="5" width="16" height="14" rx="2" stroke="currentColor" strokeWidth="2" />
5+
<line x1="9.33334" y1="19" x2="9.33333" y2="5" stroke="currentColor" strokeWidth="2" />
6+
<line x1="14.6667" y1="19" x2="14.6667" y2="5" stroke="currentColor" strokeWidth="2" />
7+
</svg>
8+
);
9+
}

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: 56 additions & 5 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,6 +42,7 @@ 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";
4748
import { type RenderIcon } from "../primitives/Icon";
@@ -144,7 +145,7 @@ const PAGINATION_PARAMS = ["cursor", "direction", "page"];
144145
* marker (presentation-only) and the pagination position (cursors go stale, and page N of a view
145146
* is not a different view). A favorite pins filters and tabs, never a transient page of them.
146147
*/
147-
export function favoritePageUrl(pathname: string, search: string): string {
148+
function favoritePageUrl(pathname: string, search: string): string {
148149
const params = new URLSearchParams(search);
149150
params.delete(FAVORITE_SEARCH_PARAM);
150151
for (const param of PAGINATION_PARAMS) {
@@ -156,7 +157,7 @@ export function favoritePageUrl(pathname: string, search: string): string {
156157

157158
/** favoritePageUrl for an already-joined URL, e.g. a favorite's stored one (which may predate
158159
* pagination stripping). */
159-
export function canonicalFavoriteUrl(url: string): string {
160+
function canonicalFavoriteUrl(url: string): string {
160161
const [pathname, search = ""] = url.split("?");
161162
return favoritePageUrl(pathname, search);
162163
}
@@ -260,7 +261,7 @@ const ACCOUNT_PAGE_META: Record<string, PageMeta> = {
260261
};
261262

262263
/** Best-effort icon + name for any dashboard page, derived from its URL shape. */
263-
export function resolvePageMeta(pathname: string): PageMeta {
264+
function resolvePageMeta(pathname: string): PageMeta {
264265
const envMatch = pathname.match(/^\/orgs\/[^/]+\/projects\/[^/]+\/env\/[^/]+(?:\/([^?]*))?$/);
265266
if (envMatch) {
266267
const segments = (envMatch[1] ?? "").split("/").filter(Boolean);
@@ -425,7 +426,7 @@ function describeFilters(search: string): string | undefined {
425426
* id for friendly-id pages: "Run: 05hrqq9n"); filtered views summarize their filters ("Runs:
426427
* Completed successfully, last 7d"). Users can always rename.
427428
*/
428-
export function buildFavoriteLabel(
429+
function buildFavoriteLabel(
429430
pathname: string,
430431
search: string,
431432
pageTitle: string | undefined
@@ -512,3 +513,53 @@ export function useFavorites(): FavoritePage[] {
512513

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

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

Lines changed: 63 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,22 @@ import {
33
PencilSquareIcon,
44
PlusIcon,
55
BoltIcon,
6-
ViewColumnsIcon,
6+
StarIcon as StarIconSolid,
77
XMarkIcon,
88
} from "@heroicons/react/20/solid";
9+
import { StarIcon as StarIconOutline } from "@heroicons/react/24/outline";
910
import { GripVerticalIcon } from "lucide-react";
1011
import { useMemo, useState } from "react";
12+
import { ColumnsIcon } from "~/assets/icons/ColumnsIcon";
13+
import { useFavoritePageToggle } from "~/components/navigation/favoritePages";
1114
import { Button } from "~/components/primitives/Buttons";
1215
import { Checkbox } from "~/components/primitives/Checkbox";
13-
import { Popover, PopoverContent, PopoverTrigger } from "~/components/primitives/Popover";
16+
import {
17+
Popover,
18+
PopoverContent,
19+
PopoverMenuItem,
20+
PopoverTrigger,
21+
} from "~/components/primitives/Popover";
1422
import { useEnvironment } from "~/hooks/useEnvironment";
1523
import { useFeatures } from "~/hooks/useFeatures";
1624
import { useOptimisticLocation } from "~/hooks/useOptimisticLocation";
@@ -42,6 +50,8 @@ export function RunsDisplayOptions({
4250
const { isManagedCloud } = useFeatures();
4351
const location = useOptimisticLocation();
4452
const { value, values, replace } = useSearchParams();
53+
// Same favorite the page-header star toggles, so the two stay in lockstep on this URL.
54+
const { isFavorited, canFavorite, toggle: toggleFavorite } = useFavoritePageToggle();
4555
const [addOpen, setAddOpen] = useState(false);
4656
const [editing, setEditing] = useState<SmartEditTarget | null>(null);
4757
const [dragKey, setDragKey] = useState<string | null>(null);
@@ -132,18 +142,24 @@ export function RunsDisplayOptions({
132142
<>
133143
<Popover>
134144
<PopoverTrigger asChild>
135-
<Button variant="secondary/small" LeadingIcon={ViewColumnsIcon}>
145+
<Button variant="secondary/small" LeadingIcon={ColumnsIcon}>
136146
Columns
137147
</Button>
138148
</PopoverTrigger>
139-
<PopoverContent align="end" className="w-64 p-0">
149+
<PopoverContent
150+
align="end"
151+
className="w-64 p-0"
152+
// Radix otherwise focuses the first item on open, and the row's hover-revealed
153+
// reorder handle would show through :focus-within before the mouse ever gets there.
154+
onOpenAutoFocus={(event) => event.preventDefault()}
155+
>
140156
<div className="flex items-center justify-between px-3 py-2">
141157
<span className="text-xs font-medium text-text-dimmed">Columns</span>
142158
<span className="text-xs text-text-dimmed">
143159
{shownCount} of {totalCount}
144160
</span>
145161
</div>
146-
<div className="max-h-80 overflow-y-auto border-y border-grid-dimmed">
162+
<div className="max-h-80 overflow-y-auto border-y border-grid-dimmed p-1 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
147163
{layout.ordered.map(({ col, hidden }) => {
148164
const key = keyFor(col);
149165
return (
@@ -174,23 +190,30 @@ export function RunsDisplayOptions({
174190
})}
175191
</div>
176192
<div className="flex flex-col p-1">
177-
<button
178-
type="button"
179-
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm text-text-bright transition-colors hover:bg-charcoal-750 focus-custom"
193+
<PopoverMenuItem
194+
icon={PlusIcon}
195+
title="Add smart column…"
180196
onClick={() => setAddOpen(true)}
181-
>
182-
<PlusIcon className="size-4 text-text-dimmed" />
183-
Add smart column…
184-
</button>
185-
<button
186-
type="button"
187-
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm text-text-dimmed transition-colors hover:bg-charcoal-750 hover:text-text-bright focus-custom disabled:opacity-50"
197+
/>
198+
{canFavorite && (
199+
<PopoverMenuItem
200+
icon={
201+
isFavorited ? (
202+
<StarIconSolid className="size-4 text-yellow-500" />
203+
) : (
204+
<StarIconOutline className="size-4" />
205+
)
206+
}
207+
title={isFavorited ? "Remove from favorites" : "Save to favorites"}
208+
onClick={toggleFavorite}
209+
/>
210+
)}
211+
<PopoverMenuItem
212+
icon={ArrowUturnLeftIcon}
213+
title="Reset to default"
188214
onClick={reset}
189215
disabled={!layout.isCustomized}
190-
>
191-
<ArrowUturnLeftIcon className="size-4" />
192-
Reset to default
193-
</button>
216+
/>
194217
</div>
195218
</PopoverContent>
196219
</Popover>
@@ -245,7 +268,7 @@ function ColumnRow({
245268
return (
246269
<div
247270
className={cn(
248-
"group relative flex h-8 items-center gap-2 pl-3 pr-1.5 transition-colors hover:bg-charcoal-750",
271+
"group relative flex h-[1.8rem] items-center rounded-sm transition-colors hover:bg-background-hover",
249272
dragging && "opacity-40"
250273
)}
251274
draggable
@@ -263,20 +286,32 @@ function ColumnRow({
263286
}}
264287
>
265288
{isOver && <div className="absolute inset-x-0 top-0 h-0.5 bg-blue-500" />}
266-
{locked ? <Checkbox checked disabled /> : <Checkbox checked={checked} onChange={onToggle} />}
267-
<span className="flex min-w-0 flex-1 items-center gap-1.5">
268-
<span className={cn("truncate text-sm", checked ? "text-text-bright" : "text-text-dimmed")}>
289+
{/* Native label so the whole name area toggles the column, matching CheckboxWithLabel. */}
290+
<label
291+
className={cn(
292+
"flex h-full min-w-0 flex-1 items-center gap-x-1.5 pl-[0.4rem]",
293+
locked ? "cursor-default" : "cursor-pointer"
294+
)}
295+
>
296+
{locked ? (
297+
<Checkbox checked disabled />
298+
) : (
299+
<Checkbox checked={checked} onChange={onToggle} />
300+
)}
301+
<span
302+
className={cn("truncate text-2sm", checked ? "text-text-bright" : "text-text-dimmed")}
303+
>
269304
{col.def.label}
270305
</span>
271306
{isSmart && <BoltIcon className="size-3.5 flex-none text-text-dimmed" />}
272-
</span>
273-
<div className="flex flex-none items-center gap-0.5">
307+
</label>
308+
<div className="flex flex-none items-center gap-0.5 pr-[0.4rem]">
274309
{onEdit && (
275310
<button
276311
type="button"
277312
onClick={onEdit}
278313
aria-label={`Edit ${col.def.label}`}
279-
className="flex size-6 items-center justify-center rounded text-text-dimmed opacity-0 transition hover:bg-charcoal-700 hover:text-text-bright focus-custom group-hover:opacity-100 group-focus-within:opacity-100"
314+
className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-text-dimmed opacity-0 transition hover:bg-charcoal-700 hover:text-text-bright focus-custom group-hover:opacity-100 group-focus-within:opacity-100"
280315
>
281316
<PencilSquareIcon className="size-4" />
282317
</button>
@@ -286,7 +321,7 @@ function ColumnRow({
286321
type="button"
287322
onClick={onRemove}
288323
aria-label={`Remove ${col.def.label}`}
289-
className="flex size-6 items-center justify-center rounded text-text-dimmed opacity-0 transition hover:bg-charcoal-700 hover:text-error focus-custom group-hover:opacity-100 group-focus-within:opacity-100"
324+
className="flex size-6 cursor-pointer items-center justify-center rounded-sm text-text-dimmed opacity-0 transition hover:bg-charcoal-700 hover:text-error focus-custom group-hover:opacity-100 group-focus-within:opacity-100"
290325
>
291326
<XMarkIcon className="size-4" />
292327
</button>
@@ -303,7 +338,7 @@ function ColumnRow({
303338
onMove(1);
304339
}
305340
}}
306-
className="flex size-6 cursor-grab items-center justify-center rounded text-text-dimmed opacity-0 transition hover:bg-charcoal-700 hover:text-text-bright focus-custom group-hover:opacity-100 group-focus-within:opacity-100 active:cursor-grabbing"
341+
className="flex size-6 cursor-grab items-center justify-center rounded-sm text-text-dimmed opacity-0 transition hover:bg-charcoal-700 hover:text-text-bright focus-custom group-hover:opacity-100 group-focus-within:opacity-100 active:cursor-grabbing"
307342
>
308343
<GripVerticalIcon className="size-4" />
309344
</button>

apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.runs._index/route.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -348,7 +348,7 @@ function RunsList({
348348
hasFilters={list.hasFilters}
349349
rootOnlyDefault={rootOnlyDefault}
350350
/>
351-
<div className="flex items-center justify-end gap-x-2">
351+
<div className="flex items-center justify-end gap-x-1.5">
352352
{showNewRunsBanner && (
353353
<span className="flex duration-150 animate-in fade-in-0">
354354
<Button

apps/webapp/app/routes/storybook.icons/route.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import { ClockIcon } from "~/assets/icons/ClockIcon";
3939
import { ClockRotateLeftIcon } from "~/assets/icons/ClockRotateLeftIcon";
4040
import { AWS, DigitalOcean } from "~/assets/icons/CloudProviderIcon";
4141
import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon";
42+
import { ColumnsIcon } from "~/assets/icons/ColumnsIcon";
4243
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
4344
import {
4445
CheckingConnectionIcon,
@@ -181,6 +182,7 @@ const icons: IconEntry[] = [
181182
{ name: "ClockIcon", render: simple(ClockIcon) },
182183
{ name: "ClockRotateLeftIcon", render: simple(ClockRotateLeftIcon) },
183184
{ name: "CodeSquareIcon", render: simple(CodeSquareIcon) },
185+
{ name: "ColumnsIcon", render: simple(ColumnsIcon) },
184186
{ name: "ConcurrencyIcon", render: simple(ConcurrencyIcon) },
185187
{ name: "ConnectedIcon", render: simple(ConnectedIcon) },
186188
{ name: "CubeSparkleIcon", render: simple(CubeSparkleIcon) },

0 commit comments

Comments
 (0)