Skip to content

Commit c9eb226

Browse files
committed
fix(webapp): filter-aware favorite labels, exact-match active state, spacing
Favoriting a filtered view now summarizes its filters in the default name ("Runs: Canceled, last 30d"), naming at most two filters and counting the rest, all derived from the URL so labels stay instant and predictable. The Tasks page filtered to a single task type uses that type as the whole name ("Agent tasks"). A favorite now only shows as active while the URL exactly matches the view it saved; changing any filter hands the active state back to the regular menu item. Header spacing: title, help icon, and star sit on an even rhythm with a subtle optical nudge on the accessory. Popovers containing Customize sidebar no longer show an extra line-box pixel under their last item.
1 parent 35476fe commit c9eb226

4 files changed

Lines changed: 147 additions & 44 deletions

File tree

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

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,9 @@ export function FavoritePageButton({
6060
const existing = favorites.find((favorite) => favorite.url === url);
6161
const isFavorited = existing !== undefined;
6262
// The tooltip names the favorite: its custom name once saved, else the label saving would use
63-
// (which includes detail-page ids, e.g. "Run: 05hrqq9n")
64-
const pageName = existing?.label ?? buildFavoriteLabel(location.pathname, pageTitle);
63+
// (which includes detail-page ids and filter summaries, e.g. "Runs: Completed, last 7d")
64+
const pageName =
65+
existing?.label ?? buildFavoriteLabel(location.pathname, location.search, pageTitle);
6566

6667
const toggle = () => {
6768
if (existing) {
@@ -75,7 +76,7 @@ export function FavoritePageButton({
7576
intent: "add",
7677
id: crypto.randomUUID(),
7778
url,
78-
label: buildFavoriteLabel(location.pathname, pageTitle),
79+
label: buildFavoriteLabel(location.pathname, location.search, pageTitle),
7980
icon: resolvePageMeta(location.pathname).icon,
8081
},
8182
{ method: "POST", action: FAVORITES_ACTION_PATH }

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

Lines changed: 16 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1414,7 +1414,8 @@ function SideMenuMoreItem({
14141414
/>
14151415
))}
14161416
</div>
1417-
<div className="border-t border-grid-bright p-1">
1417+
{/* flex-col blockifies the inline-block menu item, avoiding stray line-box space below */}
1418+
<div className="flex flex-col border-t border-grid-bright p-1">
14181419
<PopoverMenuItem
14191420
icon={PencilSquareIcon}
14201421
title="Customize sidebar"
@@ -1443,17 +1444,20 @@ function SectionHeaderMenu({ onCustomize }: { onCustomize: () => void }) {
14431444
>
14441445
<EllipsisHorizontalIcon className="size-3.5" />
14451446
</PopoverCustomTrigger>
1446-
<PopoverContent className="w-fit min-w-max p-1" align="start" sideOffset={4}>
1447-
<PopoverMenuItem
1448-
icon={PencilSquareIcon}
1449-
title="Customize sidebar"
1450-
leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
1451-
className={SIDE_MENU_POPOVER_ITEM_LABEL}
1452-
onClick={() => {
1453-
setOpen(false);
1454-
onCustomize();
1455-
}}
1456-
/>
1447+
<PopoverContent className="w-fit min-w-max p-0" align="start" sideOffset={4}>
1448+
{/* flex-col blockifies the inline-block menu item, avoiding stray line-box space below */}
1449+
<div className="flex flex-col p-1">
1450+
<PopoverMenuItem
1451+
icon={PencilSquareIcon}
1452+
title="Customize sidebar"
1453+
leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
1454+
className={SIDE_MENU_POPOVER_ITEM_LABEL}
1455+
onClick={() => {
1456+
setOpen(false);
1457+
onCustomize();
1458+
}}
1459+
/>
1460+
</div>
14571461
</PopoverContent>
14581462
</Popover>
14591463
);

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

Lines changed: 109 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -121,32 +121,38 @@ export function stripFavoriteSearchParam(search: string): string {
121121
return result.length > 0 ? `?${result}` : "";
122122
}
123123

124-
/** A favorite is active when its marker param is in the URL and the pathname still matches. */
124+
/**
125+
* A favorite is active only while the URL is exactly the view it saved: its marker param is
126+
* present AND the rest of the URL still matches. Changing any filter on the page diverges the
127+
* URL from the favorite, so it deactivates (and the regular menu item takes over).
128+
*/
125129
export function isFavoriteActive(
126130
favorite: FavoritePage,
127131
pathname: string,
128132
search: string
129133
): boolean {
130-
const favoritePath = favorite.url.split("?")[0];
131134
return (
132-
pathname === favoritePath &&
133-
new URLSearchParams(search).get(FAVORITE_SEARCH_PARAM) === favorite.id
135+
new URLSearchParams(search).get(FAVORITE_SEARCH_PARAM) === favorite.id &&
136+
favorite.url === pathname + stripFavoriteSearchParam(search)
134137
);
135138
}
136139

137140
/**
138141
* The id of the favorite driving the current view: the URL's marker param, but only when it
139-
* belongs to one of the current user's favorites. A marker arriving via someone else's shared
140-
* link (or a removed favorite's stale link) resolves to undefined, so the page loads with
141-
* regular menu highlighting instead of suppressing it.
142+
* belongs to one of the current user's favorites AND the URL still matches that favorite's
143+
* saved view. A marker from someone else's shared link, a removed favorite's stale link, or a
144+
* view whose filters have since been changed resolves to undefined, so regular menu
145+
* highlighting applies.
142146
*/
143147
export function useActiveFavoriteId(): string | undefined {
144148
const location = useLocation();
145149
const favorites = useFavorites();
146150

147151
const marker = new URLSearchParams(location.search).get(FAVORITE_SEARCH_PARAM);
148152
if (!marker) return undefined;
149-
return favorites.some((favorite) => favorite.id === marker) ? marker : undefined;
153+
const favorite = favorites.find((f) => f.id === marker);
154+
if (!favorite) return undefined;
155+
return isFavoriteActive(favorite, location.pathname, location.search) ? marker : undefined;
150156
}
151157

152158
type PageMeta = {
@@ -253,6 +259,10 @@ export function resolvePageMeta(pathname: string): PageMeta {
253259

254260
const MAX_LABEL_LENGTH = 50;
255261

262+
function truncateLabel(label: string): string {
263+
return label.length > MAX_LABEL_LENGTH ? `${label.slice(0, MAX_LABEL_LENGTH - 1)}…` : label;
264+
}
265+
256266
/**
257267
* Short id for a detail page whose last URL segment is a friendly id ("run_cmryyza…05hrqq9n").
258268
* Uses the same 8-character tail the dashboard tables display, so the label matches what the
@@ -267,31 +277,115 @@ function detailIdFromPath(pathname: string): string | undefined {
267277
return undefined;
268278
}
269279

280+
/** Task type filter on the Tasks page (?types=…) becomes the whole favorite name. */
281+
const TASK_TYPE_LABELS: Record<string, string> = {
282+
AGENT: "Agent tasks",
283+
STANDARD: "Standard tasks",
284+
SCHEDULED: "Scheduled tasks",
285+
};
286+
287+
/** "COMPLETED_SUCCESSFULLY" -> "Completed successfully", "history" -> "History". */
288+
function humanizeValue(value: string): string {
289+
const lowered = value.toLowerCase().replaceAll("_", " ");
290+
return lowered.charAt(0).toUpperCase() + lowered.slice(1);
291+
}
292+
293+
/** Pagination/UI-state params that never describe what the user filtered. */
294+
const NON_FILTER_PARAMS = [FAVORITE_SEARCH_PARAM, "cursor", "direction", "page", "span"];
295+
296+
/**
297+
* Summarize a filtered view's search params into a short, selective descriptor for the favorite
298+
* label ("Completed successfully, last 7d +2"). The best-known filters are named (at most two);
299+
* everything else only counts toward a "+N" so heavily filtered views stay readable.
300+
*/
301+
function describeFilters(search: string): string | undefined {
302+
const params = new URLSearchParams(search);
303+
for (const param of NON_FILTER_PARAMS) {
304+
params.delete(param);
305+
}
306+
307+
const parts: string[] = [];
308+
const consumed = new Set<string>();
309+
310+
const take = (key: string, describe: (values: string[]) => string | undefined) => {
311+
const values = params.getAll(key).filter((value) => value.length > 0);
312+
if (values.length === 0) return;
313+
consumed.add(key);
314+
const described = describe(values);
315+
if (described) parts.push(described);
316+
};
317+
318+
// Priority order: the filters most likely to identify the view come first
319+
take("statuses", (v) => (v.length === 1 ? humanizeValue(v[0]) : `${v.length} statuses`));
320+
take("levels", (v) => (v.length === 1 ? humanizeValue(v[0]) : `${v.length} levels`));
321+
take("tasks", (v) => (v.length === 1 ? v[0] : `${v.length} tasks`));
322+
take("queues", (v) => (v.length === 1 ? v[0].replace(/^task\//, "") : `${v.length} queues`));
323+
take("tags", (v) => (v.length === 1 ? v[0] : `${v.length} tags`));
324+
take("period", (v) => `last ${v[0]}`);
325+
if (params.has("from") || params.has("to")) {
326+
consumed.add("from");
327+
consumed.add("to");
328+
parts.push("custom range");
329+
}
330+
take("versions", (v) => (v.length === 1 ? v[0] : `${v.length} versions`));
331+
take("machines", (v) => (v.length === 1 ? v[0] : `${v.length} machines`));
332+
take("tab", (v) => humanizeValue(v[0]));
333+
// The runs list appends rootOnly=false by default; only the non-default value is a filter
334+
take("rootOnly", (v) => (v[0] === "true" ? "root only" : undefined));
335+
336+
const remaining = new Set([...params.keys()].filter((key) => !consumed.has(key))).size;
337+
338+
const MAX_NAMED_PARTS = 2;
339+
const shown = parts.slice(0, MAX_NAMED_PARTS);
340+
const extra = parts.length - shown.length + remaining;
341+
342+
if (shown.length === 0) {
343+
return extra > 0 ? `${extra} filter${extra === 1 ? "" : "s"}` : undefined;
344+
}
345+
return shown.join(", ") + (extra > 0 ? ` +${extra}` : "");
346+
}
347+
270348
/**
271-
* Compose the default side menu label for a favorited page. List pages keep their nav name
272-
* ("Queues"); detail pages get an identifying prefix ("Queue: email-queue", or the short id for
273-
* friendly-id pages: "Run: 05hrqq9n"). Users can rename.
349+
* Compose the default side menu label for a favorited page. Plain list pages keep their nav
350+
* name ("Queues"); detail pages get an identifying prefix ("Queue: email-queue", or the short
351+
* id for friendly-id pages: "Run: 05hrqq9n"); filtered views summarize their filters ("Runs:
352+
* Completed successfully, last 7d"). Users can always rename.
274353
*/
275-
export function buildFavoriteLabel(pathname: string, pageTitle: string | undefined): string {
354+
export function buildFavoriteLabel(
355+
pathname: string,
356+
search: string,
357+
pageTitle: string | undefined
358+
): string {
276359
const meta = resolvePageMeta(pathname);
277360
const title = pageTitle?.trim();
278361
const prefix = meta.singular ?? meta.name;
279362

280-
// Generic titles ("Runs", "Run") identify nothing on a detail page; prefer the short id
363+
// Generic titles ("Runs", "Run") identify nothing on their own; prefer ids/filters from the URL
281364
const isGenericTitle =
282365
!title ||
283366
title.toLowerCase() === meta.name.toLowerCase() ||
284367
title.toLowerCase() === prefix.toLowerCase();
285368

286369
if (isGenericTitle) {
370+
// The Tasks page filtered to a single task type takes that type as the whole name
371+
if (meta.icon === "tasks") {
372+
const types = new URLSearchParams(search).getAll("types");
373+
if (types.length === 1 && TASK_TYPE_LABELS[types[0]]) {
374+
return TASK_TYPE_LABELS[types[0]];
375+
}
376+
}
377+
287378
const detailId = detailIdFromPath(pathname);
288-
return detailId ? `${prefix}: ${detailId}` : meta.name;
379+
if (detailId) return `${prefix}: ${detailId}`;
380+
381+
const filters = describeFilters(search);
382+
return truncateLabel(filters ? `${meta.name}: ${filters}` : meta.name);
289383
}
290384

291385
const label = title.toLowerCase().startsWith(prefix.toLowerCase())
292386
? title
293387
: `${prefix}: ${title}`;
294-
return label.length > MAX_LABEL_LENGTH ? `${label.slice(0, MAX_LABEL_LENGTH - 1)}…` : label;
388+
return truncateLabel(label);
295389
}
296390

297391
/**

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

Lines changed: 18 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,7 @@ export function PageTitle({ title, backButton, accessory }: PageTitleProps) {
5050
const titleText = typeof title === "string" ? title : undefined;
5151

5252
return (
53-
<div className="flex items-center gap-1">
53+
<div className="flex items-center gap-1.5">
5454
{backButton && (
5555
<div className="group -ml-1.5 flex items-center gap-0">
5656
<Link
@@ -63,19 +63,23 @@ export function PageTitle({ title, backButton, accessory }: PageTitleProps) {
6363
</div>
6464
)}
6565
<Header2 className="flex items-center gap-1">{title}</Header2>
66-
{accessory !== undefined &&
67-
(typeof accessory === "string" ? (
68-
<SimpleTooltip
69-
button={<QuestionMarkIcon className="size-4 text-text-dimmed" />}
70-
content={accessory}
71-
className="max-w-xs"
72-
disableHoverableContent
73-
/>
74-
) : (
75-
accessory
76-
))}
77-
{/* -ml-1 cancels the row gap: the star's own inner padding then provides the visual gap,
78-
matching the title-to-accessory spacing while the button box stays flush for hover */}
66+
{accessory !== undefined && (
67+
// ml-px optically evens the accessory against the title's tight text edge
68+
<span className="ml-px flex items-center">
69+
{typeof accessory === "string" ? (
70+
<SimpleTooltip
71+
button={<QuestionMarkIcon className="size-4 text-text-dimmed" />}
72+
content={accessory}
73+
className="max-w-xs"
74+
disableHoverableContent
75+
/>
76+
) : (
77+
accessory
78+
)}
79+
</span>
80+
)}
81+
{/* -ml-1 pulls the star's button box near-flush: its inner padding then provides the
82+
visual gap, matching the title-to-accessory spacing while hovered */}
7983
<FavoritePageButton pageTitle={titleText} className="-ml-1" />
8084
</div>
8185
);

0 commit comments

Comments
 (0)