feat(webapp): favorite pages and sidebar customization (#4375)

## Summary

Favorite any dashboard page and it appears in a new "Favorites" section
at the top of the side menu. The star next to the page title (or
Option+F) saves the exact view, filters and tabs included, with a name
derived from the URL ("Runs: Completed successfully, last 7d", "Run:
05hrqq9n") that you can rename inline from each item's hover menu.

The sidebar is customizable too: "Customize sidebar" (on section header
menus and in each "More" menu) opens a modal where you can reorder
sections, drag items into a new order, hide items behind a per-section
"More" popover, and rename or remove favorites. Changes apply on
Confirm, Reset restores the default layout without touching favorites,
and everything is stored per user in dashboard preferences.

## Screenshots

| Favorites in the side menu | Customize sidebar modal |
| --- | --- |
| ![Favorites section with rename and remove
menu](https://raw.githubusercontent.com/triggerdotdev/trigger.dev/d56f073dc517e2073b01d8eff880183539638f03/favorites-side-menu.png)
| ![Customize sidebar
modal](https://raw.githubusercontent.com/triggerdotdev/trigger.dev/d56f073dc517e2073b01d8eff880183539638f03/customize-sidebar-modal.png)
|

![Favorite star and tooltip in the page
header](https://raw.githubusercontent.com/triggerdotdev/trigger.dev/d56f073dc517e2073b01d8eff880183539638f03/star-tooltip.png)

## Design notes

- Favorite links carry a small marker search param so the favorite, not
its identical main menu item, highlights as active. Markers from shared
or stale links are cleaned on load, and changing any filter hands the
highlight back to the regular menu item.
- Preference writes are serialized with a row lock: several writers
(debounced collapse and width saves, favorite toggles, the customize
modal) can land concurrently and would otherwise clobber each other's
read-modify-write of the JSON column.
- Option+F is matched on `event.code` with a raw listener because macOS
reports Option-modified letters as symbols, which the `event.key` based
shortcut hook can't capture.

Verified end-to-end in the browser: star toggle and shortcut, instant
section appearance, inline rename and staged modal removal, filter-aware
labels and unique active states, shared-link normalization, drag
reordering, and persistence across reloads.
This commit is contained in:
James Ritchie
2026-07-27 16:29:36 +01:00
committed by GitHub
parent efd0ee8d74
commit d30ee6e570
21 changed files with 2838 additions and 358 deletions
@@ -0,0 +1,6 @@
---
area: webapp
type: feature
---
Favorite any dashboard page to a new Favorites section in the side menu, and customize the sidebar by renaming favorites, hiding items, and reordering items and sections.
@@ -0,0 +1,14 @@
export function CrossIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path d="M6 6L18 18M18 6L6 18" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg>
);
}
@@ -0,0 +1,35 @@
export function EyeClosedIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M10.7424 5.08581C14.6841 4.54668 18.7922 6.54985 21.4978 11.0954C21.8296 11.6529 21.8298 12.3468 21.498 12.9043C21.124 13.5326 20.7233 14.1123 20.3 14.6434"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M3.70711 2.29289C3.31658 1.90237 2.68342 1.90237 2.29289 2.29289C1.90237 2.68342 1.90237 3.31658 2.29289 3.70711L3.70711 2.29289ZM20.2929 21.7071C20.6834 22.0976 21.3166 22.0976 21.7071 21.7071C22.0976 21.3166 22.0976 20.6834 21.7071 20.2929L20.2929 21.7071ZM2.29289 3.70711L20.2929 21.7071L21.7071 20.2929L3.70711 2.29289L2.29289 3.70711Z"
fill="currentColor"
/>
<path
d="M10.3327 10.8948C10.6385 10.4349 10.5136 9.81416 10.0537 9.50837C9.59377 9.20259 8.97305 9.32753 8.66727 9.78743L10.3327 10.8948ZM14.2126 15.3328C14.6725 15.027 14.7974 14.4063 14.4916 13.9463C14.1858 13.4864 13.5651 13.3615 13.1052 13.6673L14.2126 15.3328ZM12 14C10.8954 14 10 13.1046 10 12H8C8 14.2092 9.79086 16 12 16V14ZM10 12C10 11.5897 10.1225 11.211 10.3327 10.8948L8.66727 9.78743C8.24565 10.4216 8 11.1836 8 12H10ZM13.1052 13.6673C12.789 13.8775 12.4103 14 12 14V16C12.8164 16 13.5785 15.7544 14.2126 15.3328L13.1052 13.6673Z"
fill="currentColor"
/>
<path
d="M6.12815 7C4.77316 7.99438 3.53535 9.35957 2.50209 11.0955C2.17024 11.6531 2.17115 12.3487 2.50305 12.9062C6.05251 18.8681 12.0149 20.4553 16.8492 17.6681"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,27 @@
export function EyeOpenIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M15 12C15 13.6569 13.6569 15 12 15C10.3431 15 9 13.6569 9 12C9 10.3431 10.3431 9 12 9C13.6569 9 15 10.3431 15 12Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M21.4974 11.0946C16.66 2.9684 7.33998 2.96849 2.50257 11.0947C2.17069 11.6523 2.17069 12.3479 2.50257 12.9054C7.33998 21.0316 16.66 21.0315 21.4974 12.9053C21.8293 12.3477 21.8293 11.6521 21.4974 11.0946Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,27 @@
export function RenameIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 4H7.2C6.0799 4 5.51984 4 5.09202 4.21799C4.71569 4.40973 4.40973 4.71569 4.21799 5.09202C4 5.51984 4 6.0799 4 7.2V16.8C4 17.9201 4 18.4802 4.21799 18.908C4.40973 19.2843 4.71569 19.5903 5.09202 19.782C5.51984 20 6.0799 20 7.2 20H16.8C17.9201 20 18.4802 20 18.908 19.782C19.2843 19.5903 19.5903 19.2843 19.782 18.908C20 18.4802 20 17.9201 20 16.8V13"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path
d="M9 14.9999V12.4142C9 12.1489 9.10536 11.8946 9.29289 11.707L17.3358 3.66416C18.1168 2.88311 19.3832 2.88311 20.1642 3.66416L20.3358 3.83573C21.1168 4.61678 21.1168 5.88311 20.3358 6.66416L12.2929 14.707C12.1054 14.8946 11.851 14.9999 11.5858 14.9999H9Z"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="round"
/>
</svg>
);
}
@@ -0,0 +1,26 @@
export function SidebarCustomizeIcon({ className }: { className?: string }) {
return (
<svg
className={className}
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M11 5H5C3.89543 5 3 5.89543 3 7V17C3 18.1046 3.89543 19 5 19H11M11 5H19C20.1046 5 21 5.89543 21 7V17C21 18.1046 20.1046 19 19 19H11M11 5V19"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="square"
strokeLinejoin="round"
/>
<path
d="M6.125 8.75C6.125 9.23325 6.51675 9.625 7 9.625C7.48325 9.625 7.875 9.23325 7.875 8.75C7.875 8.26675 7.48325 7.875 7 7.875C6.51675 7.875 6.125 8.26675 6.125 8.75ZM6.125 12C6.125 12.4832 6.51675 12.875 7 12.875C7.48325 12.875 7.875 12.4832 7.875 12C7.875 11.5168 7.48325 11.125 7 11.125C6.51675 11.125 6.125 11.5168 6.125 12ZM6.125 15.25C6.125 15.7332 6.51675 16.125 7 16.125C7.48325 16.125 7.875 15.7332 7.875 15.25C7.875 14.7668 7.48325 14.375 7 14.375C6.51675 14.375 6.125 14.7668 6.125 15.25Z"
fill="currentColor"
stroke="currentColor"
strokeWidth="0.75"
/>
</svg>
);
}
+4
View File
@@ -73,6 +73,10 @@ function ShortcutContent() {
<ShortcutKey shortcut={{ modifiers: ["mod"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "b" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Favorite this page">
<ShortcutKey shortcut={{ modifiers: ["alt"] }} variant="medium/bright" />
<ShortcutKey shortcut={{ key: "f" }} variant="medium/bright" />
</Shortcut>
<Shortcut name="Select filter">
<ShortcutKey shortcut={{ key: "1" }} variant="medium/bright" />
<Paragraph variant="small" className="ml-1.5">
@@ -0,0 +1,506 @@
import { DialogClose } from "@radix-ui/react-dialog";
import { ArrowDownIcon, ArrowUpIcon } from "@heroicons/react/20/solid";
import { GripVerticalIcon } from "lucide-react";
import { useState } from "react";
import ReactGridLayout, { type Layout, useContainerWidth } from "react-grid-layout";
import { CrossIcon } from "~/assets/icons/CrossIcon";
import { EyeClosedIcon } from "~/assets/icons/EyeClosedIcon";
import { EyeOpenIcon } from "~/assets/icons/EyeOpenIcon";
import { cn } from "~/utils/cn";
import { Button } from "../primitives/Buttons";
import { DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog";
import { FormError } from "../primitives/FormError";
import { Header3 } from "../primitives/Headers";
import { Icon, type RenderIcon } from "../primitives/Icon";
import { Input } from "../primitives/Input";
import { isItemHidden, orderByPreference } from "./sideMenuTypes";
export type CustomizeSidebarItem = {
id: string;
name: string;
icon: RenderIcon;
iconClassName?: string;
defaultHidden?: boolean;
/** Favorites get an inline-editable name in the modal. */
isFavorite?: boolean;
};
export type CustomizeSidebarSection = {
id: string;
title: string;
/** Items in DEFAULT order (favorites: saved order — that is their default). */
items: CustomizeSidebarItem[];
};
type SavedPreferences = {
sectionOrder?: string[];
hiddenItems?: Record<string, boolean>;
sectionItemOrder?: Record<string, string[]>;
};
/** What Confirm produces; null clears a stored preference back to its default. */
export type SidebarCustomizationPayload = {
sectionOrder: string[] | null;
hiddenItems: Record<string, boolean> | null;
sectionItemOrder: Record<string, string[]> | null;
favorites?: Array<{ id: string; label: string }>;
removedFavoriteIds?: string[];
};
type DialogState = {
sectionOrder: string[];
/** section id -> item ids in order */
itemOrders: Record<string, string[]>;
/** item id -> effective hidden */
hidden: Record<string, boolean>;
/** favorite id -> label being edited */
labels: Record<string, string>;
/** favorite ids staged for removal; applied on Confirm */
removed: string[];
};
const FAVORITES_SECTION_ID = "favorites";
const ROW_HEIGHT = 44;
function buildState(
sections: CustomizeSidebarSection[],
prefs: SavedPreferences | undefined
): DialogState {
const orderedSections = prefs ? orderByPreference(sections, prefs.sectionOrder) : sections;
const itemOrders: Record<string, string[]> = {};
const hidden: Record<string, boolean> = {};
const labels: Record<string, string> = {};
for (const section of sections) {
// Favorites' array order is canonical (already applied), so saved item order only applies to
// the static sections.
const orderedItems =
prefs && section.id !== FAVORITES_SECTION_ID
? orderByPreference(section.items, prefs.sectionItemOrder?.[section.id])
: section.items;
itemOrders[section.id] = orderedItems.map((item) => item.id);
for (const item of section.items) {
hidden[item.id] = prefs
? isItemHidden(item, prefs.hiddenItems)
: (item.defaultHidden ?? false);
if (item.isFavorite) {
labels[item.id] = item.name;
}
}
}
return {
sectionOrder: orderedSections.map((section) => section.id),
itemOrders,
hidden,
labels,
removed: [],
};
}
function arraysEqual(a: string[], b: string[]) {
return a.length === b.length && a.every((value, index) => value === b[index]);
}
/**
* The "Customize sidebar" modal: reorder sections (arrows), reorder items (drag), hide/show items
* (eye), and rename favorites inline. Nothing is applied until Confirm; Reset restores the default
* layout without touching which pages are favorited.
*/
export function CustomizeSidebarDialog({
sections,
prefs,
onConfirm,
isConfirming,
confirmError,
}: {
sections: CustomizeSidebarSection[];
prefs: SavedPreferences | undefined;
/**
* Owned by the parent: closing this dialog unmounts it, so it can't run its own fetcher. The
* parent submits the payload and closes the dialog once the save lands (or reports back via
* `confirmError`), so a failed save never silently reads as a successful one.
*/
onConfirm: (payload: SidebarCustomizationPayload) => void;
/** True from Confirm until the save (and the refreshed side menu data) lands. */
isConfirming: boolean;
/** Save failure to surface next to Confirm; the dialog stays open for a retry. */
confirmError?: string;
}) {
const [state, setState] = useState<DialogState>(() => buildState(sections, prefs));
// The Favorites section disappears with its last staged-removed favorite, matching the side
// menu (which hides the section when empty)
const displayedSections = (current: DialogState) =>
current.sectionOrder
.map((id) => sections.find((section) => section.id === id))
.filter((section): section is CustomizeSidebarSection => section !== undefined)
.filter(
(section) =>
section.id !== FAVORITES_SECTION_ID ||
section.items.some((item) => !current.removed.includes(item.id))
);
const orderedSections = displayedSections(state);
const moveSection = (sectionId: string, direction: -1 | 1) => {
setState((current) => {
// Swap with the DISPLAYED neighbor: a hidden Favorites entry may still sit in
// sectionOrder between two visible sections
const displayed = displayedSections(current).map((section) => section.id);
const neighborId = displayed[displayed.indexOf(sectionId) + direction];
if (!neighborId) return current;
const next = [...current.sectionOrder];
const a = next.indexOf(sectionId);
const b = next.indexOf(neighborId);
[next[a], next[b]] = [next[b], next[a]];
return { ...current, sectionOrder: next };
});
};
const reorderItems = (sectionId: string, itemIds: string[]) => {
setState((current) => ({
...current,
itemOrders: { ...current.itemOrders, [sectionId]: itemIds },
}));
};
const toggleHidden = (itemId: string) => {
setState((current) => ({
...current,
hidden: { ...current.hidden, [itemId]: !current.hidden[itemId] },
}));
};
const setLabel = (itemId: string, label: string) => {
setState((current) => ({ ...current, labels: { ...current.labels, [itemId]: label } }));
};
const removeFavorite = (itemId: string) => {
setState((current) => ({ ...current, removed: [...current.removed, itemId] }));
};
// Reset restores the default layout (positions + visibility) but never touches favorite names
// or staged removals; Cancel is the way out of those
const reset = () =>
setState((current) => ({
...buildState(sections, undefined),
labels: current.labels,
removed: current.removed,
}));
const hasBlankLabels = sections.some((section) =>
section.items.some(
(item) =>
item.isFavorite &&
!state.removed.includes(item.id) &&
(state.labels[item.id] ?? item.name).trim().length === 0
)
);
const confirm = () => {
const defaults = buildState(sections, undefined);
const hiddenOverrides: Record<string, boolean> = {};
for (const section of sections) {
for (const item of section.items) {
if (state.removed.includes(item.id)) continue;
const isHidden = state.hidden[item.id] ?? false;
if (isHidden !== (item.defaultHidden ?? false)) {
hiddenOverrides[item.id] = isHidden;
}
}
}
const sectionItemOrder: Record<string, string[]> = {};
for (const section of sections) {
if (section.id === FAVORITES_SECTION_ID) continue;
const order = state.itemOrders[section.id] ?? [];
if (!arraysEqual(order, defaults.itemOrders[section.id] ?? [])) {
sectionItemOrder[section.id] = order;
}
}
const favoritesSection = sections.find((section) => section.id === FAVORITES_SECTION_ID);
const favoriteOrder = (state.itemOrders[FAVORITES_SECTION_ID] ?? []).filter(
(id) => !state.removed.includes(id)
);
const favoritesChanged =
favoritesSection !== undefined &&
(state.removed.length > 0 ||
!arraysEqual(favoriteOrder, defaults.itemOrders[FAVORITES_SECTION_ID] ?? []) ||
favoritesSection.items.some(
(item) =>
!state.removed.includes(item.id) &&
(state.labels[item.id] ?? item.name).trim() !== item.name
));
// Parts equal to the defaults are sent as null so the stored preference is cleared, not pinned
const payload: SidebarCustomizationPayload = {
sectionOrder: arraysEqual(state.sectionOrder, defaults.sectionOrder)
? null
: state.sectionOrder,
hiddenItems: Object.keys(hiddenOverrides).length > 0 ? hiddenOverrides : null,
sectionItemOrder: Object.keys(sectionItemOrder).length > 0 ? sectionItemOrder : null,
favorites: favoritesChanged
? favoriteOrder.map((id) => ({ id, label: state.labels[id] ?? "" }))
: undefined,
removedFavoriteIds: state.removed.length > 0 ? state.removed : undefined,
};
onConfirm(payload);
};
return (
<DialogContent className="sm:max-w-md">
<DialogHeader>Customize sidebar</DialogHeader>
{/* Bleeds through the container's right padding (-mr-4/pr-4) so the scrollbar sits at the
modal edge, and through the vertical grid gaps (-mt-1.25/-mb-4) so the scrollport (and
scrollbar) starts at the header divider and ends at the footer border. pt-3/pb-3 are
INSIDE the scrollport: resting gaps around the list that content scrolls through. */}
<div className="-mb-4 -mr-4 -mt-1.25 max-h-[60vh] space-y-6 overflow-y-auto pb-3 pr-4 pt-3 scrollbar-thin scrollbar-track-transparent scrollbar-thumb-surface-control">
{orderedSections.map((section, index) => (
<div key={section.id}>
<div className="flex items-center justify-between border-b border-grid-dimmed pb-1.5">
<Header3>{section.title}</Header3>
<div className="flex items-center gap-0.5">
<SectionMoveButton
label={`Move ${section.title} up`}
disabled={index === 0}
onClick={() => moveSection(section.id, -1)}
>
<ArrowUpIcon className="size-3.5" />
</SectionMoveButton>
<SectionMoveButton
label={`Move ${section.title} down`}
disabled={index === orderedSections.length - 1}
onClick={() => moveSection(section.id, 1)}
>
<ArrowDownIcon className="size-3.5" />
</SectionMoveButton>
</div>
</div>
<SectionItemList
section={section}
order={(state.itemOrders[section.id] ?? section.items.map((item) => item.id)).filter(
(id) => !state.removed.includes(id)
)}
hidden={state.hidden}
labels={state.labels}
onReorder={(itemIds) => reorderItems(section.id, itemIds)}
onToggleHidden={toggleHidden}
onLabelChange={setLabel}
onRemove={removeFavorite}
/>
</div>
))}
</div>
{/* Negative margins stretch the top divider across the modal's full width */}
<DialogFooter className="-mx-4 px-4">
<div className="flex items-center gap-2">
<DialogClose asChild>
<Button variant="secondary/medium">Cancel</Button>
</DialogClose>
<Button variant="secondary/medium" onClick={reset}>
Reset
</Button>
</div>
<div className="flex min-w-0 items-center gap-3">
{confirmError && !isConfirming && (
<FormError className="truncate">{confirmError}</FormError>
)}
<Button
variant="primary/medium"
onClick={confirm}
disabled={hasBlankLabels}
isLoading={isConfirming}
>
Confirm
</Button>
</div>
</DialogFooter>
</DialogContent>
);
}
function SectionMoveButton({
label,
disabled,
onClick,
children,
}: {
label: string;
disabled: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
disabled={disabled}
onClick={onClick}
className="flex size-6 items-center justify-center rounded text-text-dimmed transition-colors hover:bg-surface-control hover:text-text-bright focus-custom disabled:pointer-events-none disabled:opacity-30"
>
{children}
</button>
);
}
function SectionItemList({
section,
order,
hidden,
labels,
onReorder,
onToggleHidden,
onLabelChange,
onRemove,
}: {
section: CustomizeSidebarSection;
order: string[];
hidden: Record<string, boolean>;
labels: Record<string, string>;
onReorder: (itemIds: string[]) => void;
onToggleHidden: (itemId: string) => void;
onLabelChange: (itemId: string, label: string) => void;
onRemove: (itemId: string) => void;
}) {
const { width, containerRef } = useContainerWidth({ initialWidth: 416 });
const items = order
.map((id) => section.items.find((item) => item.id === id))
.filter((item): item is CustomizeSidebarItem => item !== undefined);
const layout = items.map((item, index) => ({ i: item.id, x: 0, y: index, w: 1, h: 1 }));
const handleDragStop = (nextLayout: Layout) => {
const sorted = [...nextLayout].sort((a, b) => a.y - b.y).map((entry) => entry.i);
if (!arraysEqual(sorted, order)) {
onReorder(sorted);
}
};
const renderRow = (item: CustomizeSidebarItem, options: { draggable: boolean }) => (
<ModalItemRow
item={item}
isHidden={hidden[item.id] ?? false}
label={labels[item.id]}
draggable={options.draggable}
onToggleHidden={() => onToggleHidden(item.id)}
onLabelChange={(label) => onLabelChange(item.id, label)}
onRemove={() => onRemove(item.id)}
/>
);
return (
<div ref={containerRef as React.Ref<HTMLDivElement>}>
{items.length >= 2 ? (
<ReactGridLayout
layout={layout}
width={width}
gridConfig={{
cols: 1,
rowHeight: ROW_HEIGHT,
margin: [0, 0] as const,
containerPadding: [0, 0] as const,
}}
resizeConfig={{ enabled: false }}
dragConfig={{ enabled: true, handle: ".customize-drag-handle" }}
onDragStop={handleDragStop}
autoSize
>
{items.map((item) => (
<div key={item.id}>{renderRow(item, { draggable: true })}</div>
))}
</ReactGridLayout>
) : (
items.map((item) => <div key={item.id}>{renderRow(item, { draggable: false })}</div>)
)}
</div>
);
}
function ModalItemRow({
item,
isHidden,
label,
draggable,
onToggleHidden,
onLabelChange,
onRemove,
}: {
item: CustomizeSidebarItem;
isHidden: boolean;
label: string | undefined;
draggable: boolean;
onToggleHidden: () => void;
onLabelChange: (label: string) => void;
onRemove: () => void;
}) {
return (
<div
className="flex items-center justify-between gap-3 border-b border-grid-dimmed"
style={{ height: ROW_HEIGHT }}
>
<div
className={cn(
"flex min-w-0 flex-1 items-center gap-2 transition-opacity",
isHidden && "opacity-50"
)}
>
<Icon
icon={item.icon}
className={cn("size-5 shrink-0 text-text-dimmed", item.iconClassName)}
/>
{item.isFavorite ? (
<>
<Input
value={label ?? item.name}
onChange={(e) => onLabelChange(e.target.value)}
variant="medium"
maxLength={64}
containerClassName="max-w-60"
aria-label={`Rename ${item.name}`}
/>
{(label ?? item.name).trim().length === 0 && (
<FormError className="shrink-0">Name can't be blank</FormError>
)}
</>
) : (
<span className="truncate text-sm text-text-bright">{item.name}</span>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{item.isFavorite && (
<button
type="button"
onClick={onRemove}
aria-label={`Remove ${item.name}`}
className="flex size-7 items-center justify-center rounded text-text-dimmed transition-colors hover:bg-error/10 hover:text-error focus-custom"
>
<CrossIcon className="size-4" />
</button>
)}
<button
type="button"
onClick={onToggleHidden}
aria-label={isHidden ? `Show ${item.name}` : `Hide ${item.name}`}
aria-pressed={isHidden}
className="flex size-7 items-center justify-center rounded text-text-dimmed transition-colors hover:bg-surface-control hover:text-text-bright focus-custom"
>
{isHidden ? <EyeClosedIcon className="size-4" /> : <EyeOpenIcon className="size-4" />}
</button>
{draggable ? (
<div className="customize-drag-handle flex size-7 cursor-grab items-center justify-center rounded text-text-dimmed transition-colors hover:text-text-bright active:cursor-grabbing">
<GripVerticalIcon className="size-4" />
</div>
) : (
<div className="size-7" />
)}
</div>
</div>
);
}
@@ -184,6 +184,7 @@ function DashboardChildMenuItem({
to={item.path}
isCollapsed={isCollapsed}
disableIconHover
yieldActiveToFavorite
action={
showDragHandle ? (
<div className="sidebar-drag-handle flex h-full w-full cursor-grab items-center justify-center rounded text-text-dimmed opacity-0 group-hover/menuitem:opacity-100 hover:text-text-bright active:cursor-grabbing">
@@ -0,0 +1,144 @@
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 { 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";
/**
* The star in the page header that favorites the current page (full URL, including filters and
* tabs) to the side menu. Toggled by click or Option+F.
*/
export function FavoritePageButton({
pageTitle,
className,
}: {
pageTitle?: string;
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);
// 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.
const marker = new URLSearchParams(location.search).get(FAVORITE_SEARCH_PARAM);
const hasForeignMarker =
user !== undefined && marker !== null && !favorites.some((f) => f.id === marker);
useEffect(() => {
if (!hasForeignMarker) return;
setSearchParams(
(previous) => {
const next = new URLSearchParams(previous);
next.delete(FAVORITE_SEARCH_PARAM);
return next;
},
{ 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;
// 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
// bare "f" filter shortcut separate.
useShortcutKeys({
shortcut: { key: "f", modifiers: ["alt"] },
action: (event) => {
event.preventDefault();
toggle();
},
disabled: !showButton,
});
if (!showButton) {
return null;
}
const tooltipLabel = isFavorited
? `Remove ${pageName} from favorites`
: `Add ${pageName} to favorites`;
return (
<SimpleTooltip
delayDuration={500}
disableHoverableContent
asChild
side="bottom"
button={
// Span wrapper: Button drops the pointer-event props Radix injects via asChild, so the
// tooltip trigger has to be a plain element (same pattern as CollapseMenuButton).
<span className={cn("flex", className)}>
<Button
variant="minimal/small"
className="aspect-square h-6 p-1"
onClick={toggle}
aria-label={tooltipLabel}
aria-pressed={isFavorited}
LeadingIcon={
isFavorited ? (
<StarIconSolid className="size-4 text-yellow-500" />
) : (
<StarIconOutline className="size-4 text-text-dimmed transition-colors group-hover/button:text-text-bright" />
)
}
/>
</span>
}
content={
<span className="flex items-center gap-2">
{tooltipLabel}
<ShortcutKey shortcut={{ modifiers: ["alt"], key: "f" }} variant="medium/bright" />
</span>
}
/>
);
}
@@ -0,0 +1,198 @@
import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid";
import { useLocation, useNavigation } from "@remix-run/react";
import { useEffect, useRef, useState } from "react";
import { CrossIcon } from "~/assets/icons/CrossIcon";
import { RenameIcon } from "~/assets/icons/RenameIcon";
import { useIsImpersonating } from "~/hooks/useOrganizations";
import { type FavoritePage } from "~/services/dashboardPreferences.server";
import { cn } from "~/utils/cn";
import { Icon, type RenderIcon } from "../primitives/Icon";
import {
Popover,
PopoverContent,
PopoverCustomTrigger,
PopoverMenuItem,
} from "../primitives/Popover";
import {
favoriteLinkTo,
favoritePageActiveColor,
favoritePageIcon,
favoritePageIconClassName,
isFavoriteActive,
} from "./favoritePages";
import { SideMenuItem } from "./SideMenuItem";
import { SIDE_MENU_POPOVER_ITEM_ICON, SIDE_MENU_POPOVER_ITEM_LABEL } from "./sideMenuTypes";
/**
* A favorited page in the side menu. Renders like a normal menu item, with an ellipsis menu
* (Rename/Remove) that appears on hover, and an inline-editable label while renaming.
*
* Mutations are submitted by the SideMenu (not here): removing a favorite unmounts this item
* optimistically, and a fetcher owned by an unmounting component gets its request aborted.
*/
export function FavoriteMenuItem({
favorite,
isCollapsed,
onRemove,
onRename,
}: {
favorite: FavoritePage;
isCollapsed: boolean;
onRemove: (id: string) => void;
onRename: (id: string, label: string) => void;
}) {
const location = useLocation();
const navigation = useNavigation();
const isImpersonating = useIsImpersonating();
const [isEditing, setIsEditing] = useState(false);
const [isMenuOpen, setMenuOpen] = useState(false);
// Watch search too: navigating to a favorite can change only the search on the same pathname
useEffect(() => {
setMenuOpen(false);
}, [navigation.location?.pathname, navigation.location?.search]);
const icon = favoritePageIcon(favorite.icon);
const isActive = isFavoriteActive(favorite, location.pathname, location.search);
const submitRename = (value: string) => {
setIsEditing(false);
const label = value.trim();
// An empty or unchanged submit reverts to the saved label
if (label.length === 0 || label === favorite.label) return;
onRename(favorite.id, label);
};
if (isEditing && !isCollapsed) {
return (
<FavoriteRenameRow
label={favorite.label}
icon={icon}
iconClassName={favoritePageIconClassName(favorite.icon)}
onSubmit={submitRename}
onCancel={() => setIsEditing(false)}
/>
);
}
return (
<SideMenuItem
name={favorite.label}
icon={icon}
iconClassName={favoritePageIconClassName(favorite.icon)}
activeIconColor={favoritePageActiveColor(favorite.icon)}
inactiveIconColor="text-text-dimmed"
to={favoriteLinkTo(favorite)}
isCollapsed={isCollapsed}
isActive={isActive}
data-action="favorite"
action={
// Renaming and removing are preference writes, which impersonated sessions skip, so the
// menu is left out there rather than appearing to work and reverting.
!isCollapsed && !isImpersonating ? (
<Popover open={isMenuOpen} onOpenChange={setMenuOpen}>
<PopoverCustomTrigger
aria-label={`Favorite options for ${favorite.label}`}
className={cn(
// transition-none: the trigger base has `transition`, which fades the reveal in
"flex h-full w-full items-center justify-center justify-items-center rounded p-0 transition-none hover:bg-surface-control",
// Hidden until the row is hovered (or while this menu is open)
"opacity-0 group-hover/menuitem:opacity-100 data-[state=open]:opacity-100"
)}
>
<EllipsisHorizontalIcon className="size-4" />
</PopoverCustomTrigger>
<PopoverContent
className="w-fit min-w-36 p-1"
align="start"
side="right"
sideOffset={4}
>
<div className="flex flex-col gap-1">
<PopoverMenuItem
icon={RenameIcon}
title="Rename"
leadingIconClassName={SIDE_MENU_POPOVER_ITEM_ICON}
className={SIDE_MENU_POPOVER_ITEM_LABEL}
onClick={() => {
setMenuOpen(false);
setIsEditing(true);
}}
/>
<PopoverMenuItem
icon={CrossIcon}
title="Remove"
danger
leadingIconClassName="h-5 w-5"
className={SIDE_MENU_POPOVER_ITEM_LABEL}
onClick={() => {
setMenuOpen(false);
onRemove(favorite.id);
}}
/>
</div>
</PopoverContent>
</Popover>
) : undefined
}
/>
);
}
/**
* The inline rename state of a favorite: same row shape as the menu item, but the label is an
* input. Enter/blur commit, Escape reverts; empty submits revert to the previous name.
*/
function FavoriteRenameRow({
label,
icon,
iconClassName,
onSubmit,
onCancel,
}: {
label: string;
icon: RenderIcon;
iconClassName?: string;
onSubmit: (value: string) => void;
onCancel: () => void;
}) {
const inputRef = useRef<HTMLInputElement>(null);
const [value, setValue] = useState(label);
// Escape unmounts the row, which fires blur — this stops the blur from committing the edit
const cancelledRef = useRef(false);
useEffect(() => {
inputRef.current?.select();
}, []);
return (
<div className="flex h-8 w-full items-center gap-2 rounded bg-background-hover pl-1.75 pr-2">
<Icon icon={icon} className={cn("size-5 shrink-0 text-text-dimmed", iconClassName)} />
<input
ref={inputRef}
autoFocus
value={value}
maxLength={64}
aria-label="Favorite name"
onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onSubmit(value);
} else if (e.key === "Escape") {
e.preventDefault();
e.stopPropagation();
cancelledRef.current = true;
onCancel();
}
}}
onBlur={() => {
if (!cancelledRef.current) {
onSubmit(value);
}
}}
className="h-6 w-full min-w-0 flex-1 rounded-sm bg-transparent text-[0.90625rem] font-medium tracking-[-0.01em] text-text-bright outline-none"
/>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -3,6 +3,9 @@ import {
type ButtonHTMLAttributes,
forwardRef,
type ReactNode,
useEffect,
useRef,
useState,
} from "react";
import { Link } from "@remix-run/react";
import { motion } from "framer-motion";
@@ -10,6 +13,60 @@ import { usePathName } from "~/hooks/usePathName";
import { cn } from "~/utils/cn";
import { type RenderIcon, Icon } from "../primitives/Icon";
import { SimpleTooltip } from "../primitives/Tooltip";
import { useActiveFavoriteId } from "./favoritePages";
/** Right-edge fade shown instead of a hard clip, only while the label actually overflows. */
const LABEL_OVERFLOW_MASK = "linear-gradient(to right, black calc(100% - 1.5rem), transparent)";
/**
* A menu label that fades out at its right edge when (and only when) the text overflows. Text
* that fits renders exactly as before, with no mask. Overflow is re-measured when the element
* resizes (e.g. while drag-resizing the menu) and when the label text changes.
*/
export function SideMenuLabel({
children,
className,
style,
}: {
children: ReactNode;
className?: string;
style?: React.CSSProperties;
}) {
const ref = useRef<HTMLSpanElement>(null);
const [isOverflowing, setIsOverflowing] = useState(false);
const checkRef = useRef<() => void>(() => {});
useEffect(() => {
const el = ref.current;
if (!el) return;
const check = () => setIsOverflowing(el.scrollWidth > el.clientWidth + 1);
checkRef.current = check;
check();
const observer = new ResizeObserver(check);
observer.observe(el);
return () => observer.disconnect();
}, []);
// Re-measure when the text changes (a rename can flip overflow without resizing the element)
useEffect(() => {
checkRef.current();
}, [children]);
return (
<span
ref={ref}
className={cn("overflow-hidden whitespace-nowrap", className)}
style={{
...style,
...(isOverflowing
? { maskImage: LABEL_OVERFLOW_MASK, WebkitMaskImage: LABEL_OVERFLOW_MASK }
: undefined),
}}
>
{children}
</span>
);
}
export function SideMenuItem({
icon,
@@ -27,6 +84,8 @@ export function SideMenuItem({
action,
disableIconHover = false,
indented = false,
isActive: isActiveOverride,
yieldActiveToFavorite = false,
"data-action": dataAction,
}: {
icon?: RenderIcon;
@@ -45,10 +104,24 @@ export function SideMenuItem({
disableIconHover?: boolean;
/** Indented variant for grouped sub-items; only applied when the menu is expanded. */
indented?: boolean;
/** Overrides the default pathname === to active check (e.g. favorites match on full URL). */
isActive?: boolean;
/**
* In menus that render the Favorites section (the main project menu), an active favorite owns
* the highlight, so the plain item yields its active state to it. Menus without a favorites
* list (org settings, account) must not set this: they have no favorite item to carry the
* highlight instead.
*/
yieldActiveToFavorite?: boolean;
"data-action"?: string;
}) {
const pathName = usePathName();
const isActive = pathName === to;
// Only the user's OWN favorites own a view (via the marker param); markers from shared links
// don't count (see useActiveFavoriteId).
const activeFavoriteId = useActiveFavoriteId();
const isActive =
isActiveOverride ??
(pathName === to && (!yieldActiveToFavorite || activeFavoriteId === undefined));
const isIndented = indented && !isCollapsed;
@@ -92,14 +165,14 @@ export function SideMenuItem({
className="flex w-full min-w-0 items-center justify-between"
style={{ opacity: "var(--sm-label-opacity, 1)" }}
>
<span
<SideMenuLabel
className={cn(
"select-none overflow-hidden whitespace-nowrap text-[0.90625rem] font-medium tracking-[-0.01em]",
"min-w-0 flex-1 select-none text-left text-[0.90625rem] font-medium tracking-[-0.01em]",
nameClassName
)}
>
{name}
</span>
</SideMenuLabel>
{badge && !isCollapsed && (
<div className="ml-1 flex shrink-0 items-center gap-1">{badge}</div>
)}
@@ -189,9 +262,9 @@ export const SideMenuItemButton = forwardRef<
icon={icon}
className="size-5 shrink-0 text-text-dimmed group-hover/menuitem:text-text-bright"
/>
<span className="min-w-0 flex-1 select-none truncate text-[0.90625rem] font-medium tracking-[-0.01em]">
<SideMenuLabel className="min-w-0 flex-1 select-none text-left text-[0.90625rem] font-medium tracking-[-0.01em]">
{name}
</span>
</SideMenuLabel>
{trailing && <span className="flex shrink-0 items-center gap-1">{trailing}</span>}
</button>
);
@@ -12,6 +12,11 @@ type Props = {
itemSpacingClassName?: string;
/** Optional action element (e.g., + button) to render on the right side of the header */
headerAction?: React.ReactNode;
/**
* Optional menu (e.g. an ellipsis popover) overlaid on the right of the header. Only visible
* while hovering the header row, or while its popover is open.
*/
headerMenu?: React.ReactNode;
};
/** A collapsible section for the side menu. Collapsed state is controlled via props + a toggle callback. */
@@ -23,6 +28,7 @@ export function SideMenuSection({
isSideMenuCollapsed = false,
itemSpacingClassName = "space-y-px",
headerAction,
headerMenu,
}: Props) {
const [isCollapsed, setIsCollapsed] = useState(initialCollapsed);
const contentRef = useRef<HTMLDivElement>(null);
@@ -45,7 +51,7 @@ export function SideMenuSection({
return (
<div className="w-full overflow-hidden">
{/* Header container - stays in DOM to preserve height */}
<div className="relative w-full">
<div className="group/sectionheader relative w-full">
{/*
Header fades out as the menu narrows via --sm-label-opacity (falls back to 1 unset). Hover
background and text color snap (no transition), matching the nav items.
@@ -53,8 +59,9 @@ export function SideMenuSection({
<button
type="button"
// A real button for native keyboard toggle + focus ring. Out of the tab order when the
// menu is collapsed (the header is hidden and can't be toggled).
className="group/section flex w-full cursor-pointer items-center justify-between overflow-hidden rounded-sm py-1 pl-1.5 pr-1 hover:bg-background-hover focus-custom"
// menu is collapsed (the header is hidden and can't be toggled). Hover styles key off
// the wrapper group so the header stays highlighted while hovering the overlaid menu.
className="group/section flex w-full cursor-pointer items-center justify-between overflow-hidden rounded-sm py-1 pl-1.5 pr-1 group-hover/sectionheader:bg-background-hover focus-custom"
onClick={isSideMenuCollapsed ? undefined : handleToggle}
tabIndex={isSideMenuCollapsed ? -1 : undefined}
aria-expanded={!isCollapsed}
@@ -63,7 +70,7 @@ export function SideMenuSection({
cursor: isSideMenuCollapsed ? "default" : "pointer",
}}
>
<div className="flex items-center gap-1 text-text-dimmed group-hover/section:text-text-bright">
<div className="flex items-center gap-1 text-text-dimmed group-hover/sectionheader:text-text-bright">
<h2 className="whitespace-nowrap text-xs">{title}</h2>
<motion.div
initial={isCollapsed}
@@ -75,6 +82,20 @@ export function SideMenuSection({
</div>
{headerAction && <div className="flex items-center">{headerAction}</div>}
</button>
{headerMenu !== undefined &&
!isSideMenuCollapsed && (
// Outer div fades with the labels (inline style would defeat the hover opacity classes
// on the inner div, so they're split).
<div
className="absolute right-1 top-1/2 -translate-y-1/2"
style={{ opacity: "var(--sm-label-opacity, 1)" }}
>
{/* focus-within keeps the trigger visible for keyboard users tabbing onto it */}
<div className="opacity-0 focus-within:opacity-100 has-[[data-state=open]]:opacity-100 group-hover/sectionheader:opacity-100">
{headerMenu}
</div>
</div>
)}
{/*
Divider fades in via --sm-collapse (0 → 1) as the header fades out. Only while expanded.
*/}
@@ -0,0 +1,511 @@
import { BeakerIcon } from "@heroicons/react/24/outline";
import { IconChartHistogram } from "@tabler/icons-react";
import { useFetchers, useLocation } from "@remix-run/react";
import { ClockIcon } from "~/assets/icons/ClockIcon";
import { CubeSparkleIcon } from "~/assets/icons/CubeSparkleIcon";
import { TaskIconSmall } from "~/assets/icons/TaskIcon";
import { AIChatIcon } from "~/assets/icons/AIChatIcon";
import { AIMetricsIcon } from "~/assets/icons/AIMetricsIcon";
import { AIPenIcon } from "~/assets/icons/AIPenIcon";
import { AvatarCircleIcon } from "~/assets/icons/AvatarCircleIcon";
import { BatchesIcon } from "~/assets/icons/BatchesIcon";
import { BellIcon } from "~/assets/icons/BellIcon";
import { Box3DIcon } from "~/assets/icons/Box3DIcon";
import { BugIcon } from "~/assets/icons/BugIcon";
import { ChainLinkIcon } from "~/assets/icons/ChainLinkIcon";
import { ChartArrowIcon } from "~/assets/icons/ChartArrowIcon";
import { ChartBarIcon } from "~/assets/icons/ChartBarIcon";
import { CodeSquareIcon } from "~/assets/icons/CodeSquareIcon";
import { ConcurrencyIcon } from "~/assets/icons/ConcurrencyIcon";
import { CreditCardIcon } from "~/assets/icons/CreditCardIcon";
import { DeploymentsIcon } from "~/assets/icons/DeploymentsIcon";
import { DialIcon } from "~/assets/icons/DialIcon";
import { BranchEnvironmentIconSmall } from "~/assets/icons/EnvironmentIcons";
import { FolderOpenIcon } from "~/assets/icons/FolderOpenIcon";
import { GlobeLinesIcon } from "~/assets/icons/GlobeLinesIcon";
import { IDIcon } from "~/assets/icons/IDIcon";
import { IntegrationsIcon } from "~/assets/icons/IntegrationsIcon";
import { KeyIcon } from "~/assets/icons/KeyIcon";
import { ListCheckedIcon } from "~/assets/icons/ListCheckedIcon";
import { LogsIcon } from "~/assets/icons/LogsIcon";
import { PadlockIcon } from "~/assets/icons/PadlockIcon";
import { QueuesIcon } from "~/assets/icons/QueuesIcon";
import { RolesIcon } from "~/assets/icons/RolesIcon";
import { RunsIcon } from "~/assets/icons/RunsIcon";
import { ShieldIcon } from "~/assets/icons/ShieldIcon";
import { SlackIcon } from "~/assets/icons/SlackIcon";
import { SlidersIcon } from "~/assets/icons/SlidersIcon";
import { StarIcon } from "~/assets/icons/StarIcon";
import { TasksIcon } from "~/assets/icons/TasksIcon";
import { UsageIcon } from "~/assets/icons/UsageIcon";
import { UserGroupIcon } from "~/assets/icons/UserGroupIcon";
import { WaitpointTokenIcon } from "~/assets/icons/WaitpointTokenIcon";
import { VercelLogo } from "~/components/integrations/VercelLogo";
import { useOptionalUser } from "~/hooks/useUser";
import { type FavoritePage } from "~/services/dashboardPreferences.server";
import { type RenderIcon } from "../primitives/Icon";
export const FAVORITES_ACTION_PATH = "/resources/preferences/favorites";
/**
* Marker search param appended to favorite links. It makes a favorited URL distinct from its
* plain counterpart, so only the favorite (never the matching main menu item) highlights as
* active, and the marker identifies WHICH favorite when several share a pathname.
*/
export const FAVORITE_SEARCH_PARAM = "fav";
/**
* Icons a favorited page can be saved with, keyed by a stable string so preferences never store
* component references, plus the icon color used when the favorite is the active page and an
* optional size override for icons drawn without internal padding (brand logos). Unknown keys
* fall back to the star.
*/
const FAVORITE_PAGE_ICONS: Record<
string,
{ icon: RenderIcon; activeColor: string; className?: string }
> = {
tasks: { icon: TasksIcon, activeColor: "text-tasks" },
// Task detail pages carry their task-type icon, matching TaskTriggerSourceIcon
"task-standard": { icon: TaskIconSmall, activeColor: "text-tasks" },
"task-scheduled": { icon: ClockIcon, activeColor: "text-schedules" },
"task-agent": { icon: CubeSparkleIcon, activeColor: "text-agents" },
runs: { icon: RunsIcon, activeColor: "text-runs" },
sessions: { icon: AIChatIcon, activeColor: "text-sessions" },
prompts: { icon: AIPenIcon, activeColor: "text-aiPrompts" },
models: { icon: Box3DIcon, activeColor: "text-models" },
logs: { icon: LogsIcon, activeColor: "text-logs" },
errors: { icon: BugIcon, activeColor: "text-errors" },
query: { icon: CodeSquareIcon, activeColor: "text-query" },
queues: { icon: QueuesIcon, activeColor: "text-queues" },
dashboards: { icon: ChartBarIcon, activeColor: "text-metrics" },
"run-metrics": { icon: ChartArrowIcon, activeColor: "text-runs" },
"ai-metrics": { icon: AIMetricsIcon, activeColor: "text-aiMetrics" },
"custom-dashboard": { icon: IconChartHistogram, activeColor: "text-text-bright" },
deployments: { icon: DeploymentsIcon, activeColor: "text-deployments" },
"environment-variables": { icon: IDIcon, activeColor: "text-environmentVariables" },
branches: { icon: BranchEnvironmentIconSmall, activeColor: "text-previewBranches" },
regions: { icon: GlobeLinesIcon, activeColor: "text-regions" },
waitpoints: { icon: WaitpointTokenIcon, activeColor: "text-sky-500" },
batches: { icon: BatchesIcon, activeColor: "text-batches" },
"bulk-actions": { icon: ListCheckedIcon, activeColor: "text-text-bright" },
apikeys: { icon: KeyIcon, activeColor: "text-text-bright" },
alerts: { icon: BellIcon, activeColor: "text-text-bright" },
concurrency: { icon: ConcurrencyIcon, activeColor: "text-text-bright" },
limits: { icon: DialIcon, activeColor: "text-text-bright" },
schedules: { icon: ClockIcon, activeColor: "text-schedules" },
test: { icon: BeakerIcon, activeColor: "text-text-bright" },
"project-settings": { icon: SlidersIcon, activeColor: "text-text-bright" },
integrations: { icon: IntegrationsIcon, activeColor: "text-text-bright" },
// Brand logos have no internal padding, so they render one step smaller (matching the org menu)
slack: { icon: SlackIcon, activeColor: "text-text-bright", className: "size-4" },
vercel: { icon: VercelLogo, activeColor: "text-text-bright", className: "size-4" },
project: { icon: FolderOpenIcon, activeColor: "text-text-bright" },
"org-settings": { icon: SlidersIcon, activeColor: "text-text-bright" },
team: { icon: UserGroupIcon, activeColor: "text-text-bright" },
billing: { icon: CreditCardIcon, activeColor: "text-text-bright" },
usage: { icon: UsageIcon, activeColor: "text-text-bright" },
roles: { icon: RolesIcon, activeColor: "text-text-bright" },
sso: { icon: PadlockIcon, activeColor: "text-text-bright" },
"private-connections": { icon: ChainLinkIcon, activeColor: "text-text-bright" },
account: { icon: AvatarCircleIcon, activeColor: "text-text-bright" },
tokens: { icon: ShieldIcon, activeColor: "text-text-bright" },
security: { icon: PadlockIcon, activeColor: "text-text-bright" },
page: { icon: StarIcon, activeColor: "text-text-bright" },
};
export function favoritePageIcon(iconKey: string | undefined): RenderIcon {
return (iconKey ? FAVORITE_PAGE_ICONS[iconKey]?.icon : undefined) ?? StarIcon;
}
export function favoritePageActiveColor(iconKey: string | undefined): string {
return (iconKey ? FAVORITE_PAGE_ICONS[iconKey]?.activeColor : undefined) ?? "text-text-bright";
}
/** Size override for favorite icons that need one (see FAVORITE_PAGE_ICONS). */
export function favoritePageIconClassName(iconKey: string | undefined): string | undefined {
return iconKey ? FAVORITE_PAGE_ICONS[iconKey]?.className : undefined;
}
/** Href for a favorite: its saved URL plus the marker param (see FAVORITE_SEARCH_PARAM). */
export function favoriteLinkTo(favorite: FavoritePage): string {
const [path, search = ""] = favorite.url.split("?");
const params = new URLSearchParams(search);
params.set(FAVORITE_SEARCH_PARAM, favorite.id);
return `${path}?${params.toString()}`;
}
/** Pagination position params: never part of a favorite's identity (see favoritePageUrl). */
const PAGINATION_PARAMS = ["cursor", "direction", "page"];
/**
* The canonical URL a favorite saves and matches against: the path and search minus the favorite
* 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 {
const params = new URLSearchParams(search);
params.delete(FAVORITE_SEARCH_PARAM);
for (const param of PAGINATION_PARAMS) {
params.delete(param);
}
const result = params.toString();
return pathname + (result.length > 0 ? `?${result}` : "");
}
/** favoritePageUrl for an already-joined URL, e.g. a favorite's stored one (which may predate
* pagination stripping). */
export function canonicalFavoriteUrl(url: string): string {
const [pathname, search = ""] = url.split("?");
return favoritePageUrl(pathname, search);
}
/**
* A favorite is active only while the URL is the view it saved: its marker param is present AND
* the canonical URL still matches. Changing any filter on the page diverges the URL from the
* favorite, so it deactivates (and the regular menu item takes over) — but paging within the
* view keeps it active, matching what the favorite pins.
*/
export function isFavoriteActive(
favorite: FavoritePage,
pathname: string,
search: string
): boolean {
return (
new URLSearchParams(search).get(FAVORITE_SEARCH_PARAM) === favorite.id &&
canonicalFavoriteUrl(favorite.url) === favoritePageUrl(pathname, search)
);
}
/**
* The id of the favorite driving the current view: the URL's marker param, but only when it
* belongs to one of the current user's favorites AND the URL still matches that favorite's
* saved view. A marker from someone else's shared link, a removed favorite's stale link, or a
* view whose filters have since been changed resolves to undefined, so regular menu
* highlighting applies.
*/
export function useActiveFavoriteId(): string | undefined {
const location = useLocation();
const favorites = useFavorites();
const marker = new URLSearchParams(location.search).get(FAVORITE_SEARCH_PARAM);
if (!marker) return undefined;
const favorite = favorites.find((f) => f.id === marker);
if (!favorite) return undefined;
return isFavoriteActive(favorite, location.pathname, location.search) ? marker : undefined;
}
type PageMeta = {
/** Key into FAVORITE_PAGE_ICONS. */
icon: string;
/** The page's name as shown in navigation, e.g. "Queues". */
name: string;
/** Singular label-prefix for detail pages, e.g. "Queue" -> "Queue: my-queue". */
singular?: string;
/**
* Entity name taken from the URL, used verbatim as the label. For detail pages whose header
* title is composed JSX (task/agent pages render an icon + slug), so no plain-text title
* reaches the star. The icon already conveys the type, so no prefix is added.
*/
entityName?: string;
};
const ENV_PAGE_META: Record<string, PageMeta> = {
"": { icon: "tasks", name: "Tasks", singular: "Task" },
runs: { icon: "runs", name: "Runs", singular: "Run" },
sessions: { icon: "sessions", name: "Sessions", singular: "Session" },
prompts: { icon: "prompts", name: "Prompts", singular: "Prompt" },
models: { icon: "models", name: "Models", singular: "Model" },
logs: { icon: "logs", name: "Logs" },
errors: { icon: "errors", name: "Errors", singular: "Error" },
query: { icon: "query", name: "Query" },
queues: { icon: "queues", name: "Queues", singular: "Queue" },
dashboards: { icon: "dashboards", name: "Dashboards", singular: "Dashboard" },
deployments: { icon: "deployments", name: "Deploys", singular: "Deploy" },
"environment-variables": { icon: "environment-variables", name: "Environment variables" },
branches: { icon: "branches", name: "Preview branches", singular: "Branch" },
regions: { icon: "regions", name: "Regions" },
waitpoints: { icon: "waitpoints", name: "Waitpoint tokens", singular: "Waitpoint" },
batches: { icon: "batches", name: "Batches", singular: "Batch" },
"bulk-actions": { icon: "bulk-actions", name: "Bulk actions", singular: "Bulk action" },
apikeys: { icon: "apikeys", name: "API keys" },
alerts: { icon: "alerts", name: "Alerts", singular: "Alert" },
concurrency: { icon: "concurrency", name: "Concurrency" },
limits: { icon: "limits", name: "Limits" },
schedules: { icon: "schedules", name: "Schedules", singular: "Schedule" },
test: { icon: "test", name: "Test", singular: "Test" },
// The playground route is the Test page too (its header reads "Test")
playground: { icon: "test", name: "Test", singular: "Test" },
};
const ORG_SETTINGS_PAGE_META: Record<string, PageMeta> = {
"": { icon: "org-settings", name: "Organization settings" },
team: { icon: "team", name: "Team" },
billing: { icon: "billing", name: "Billing" },
"billing-limits": { icon: "alerts", name: "Billing alerts" },
usage: { icon: "usage", name: "Usage" },
roles: { icon: "roles", name: "Roles" },
sso: { icon: "sso", name: "SSO" },
"private-connections": { icon: "private-connections", name: "Private connections" },
integrations: { icon: "integrations", name: "Integrations" },
danger: { icon: "org-settings", name: "Danger zone" },
};
const ACCOUNT_PAGE_META: Record<string, PageMeta> = {
"": { icon: "account", name: "Profile" },
tokens: { icon: "tokens", name: "Personal Access Tokens" },
security: { icon: "security", name: "Security" },
};
/** Best-effort icon + name for any dashboard page, derived from its URL shape. */
export function resolvePageMeta(pathname: string): PageMeta {
const envMatch = pathname.match(/^\/orgs\/[^/]+\/projects\/[^/]+\/env\/[^/]+(?:\/([^?]*))?$/);
if (envMatch) {
const segments = (envMatch[1] ?? "").split("/").filter(Boolean);
const first = segments[0] ?? "";
if (first === "settings") {
return segments[1] === "integrations"
? { icon: "integrations", name: "Integrations" }
: { icon: "project-settings", name: "Project settings" };
}
if (first === "dashboards") {
// The built-in metric dashboards and custom dashboards have their own identities (and icons)
if (segments[1] === "overview") return { icon: "run-metrics", name: "Run metrics" };
if (segments[1] === "llm") return { icon: "ai-metrics", name: "AI metrics" };
if (segments[1] === "custom") {
return { icon: "custom-dashboard", name: "Dashboards", singular: "Dashboard" };
}
}
// Task detail: /tasks/{standard|scheduled}/{slug}. The slug is the only place the task name
// exists (the page header renders it as JSX), so it becomes the label.
if (first === "tasks" && segments[2]) {
const slug = decodeURIComponent(segments[2]);
return segments[1] === "scheduled"
? { icon: "task-scheduled", name: "Scheduled task", entityName: slug }
: { icon: "task-standard", name: "Standard task", entityName: slug };
}
// Agent tasks live outside /tasks: /agents/{slug}
if (first === "agents") {
return segments[1]
? {
icon: "task-agent",
name: "Agent task",
entityName: decodeURIComponent(segments[1]),
}
: { icon: "task-agent", name: "Agents" };
}
return ENV_PAGE_META[first] ?? { icon: "page", name: "Page" };
}
const orgSettingsMatch = pathname.match(/^\/orgs\/[^/]+\/settings(?:\/([^?]*))?$/);
if (orgSettingsMatch) {
const segments = (orgSettingsMatch[1] ?? "").split("/").filter(Boolean);
if (segments[0] === "integrations" && segments[1] === "slack") {
return { icon: "slack", name: "Slack integration" };
}
if (segments[0] === "integrations" && segments[1] === "vercel") {
return { icon: "vercel", name: "Vercel integration" };
}
return ORG_SETTINGS_PAGE_META[segments[0] ?? ""] ?? { icon: "org-settings", name: "Settings" };
}
if (/^\/orgs\/[^/]+\/projects\/[^/]+/.test(pathname)) {
return { icon: "project", name: "Project" };
}
if (/^\/orgs\/[^/]+/.test(pathname)) {
return { icon: "project", name: "Projects" };
}
const accountMatch = pathname.match(/^\/account(?:\/([^?]*))?$/);
if (accountMatch) {
const segments = (accountMatch[1] ?? "").split("/").filter(Boolean);
return ACCOUNT_PAGE_META[segments[0] ?? ""] ?? { icon: "account", name: "Account" };
}
return { icon: "page", name: "Page" };
}
const MAX_LABEL_LENGTH = 50;
function truncateLabel(label: string): string {
return label.length > MAX_LABEL_LENGTH ? `${label.slice(0, MAX_LABEL_LENGTH - 1)}` : label;
}
/**
* Short id for a detail page whose last URL segment is a friendly id ("run_cmryyza…05hrqq9n").
* Uses the same 8-character tail the dashboard tables display, so the label matches what the
* user sees elsewhere.
*/
function detailIdFromPath(pathname: string): string | undefined {
const segments = pathname.split("/").filter(Boolean);
const last = segments[segments.length - 1];
if (last && /^(run|batch|session|deployment|schedule|waitpoint)_[a-z0-9]{8,}$/i.test(last)) {
return last.slice(-8);
}
return undefined;
}
/** Task type filter on the Tasks page (?types=…) becomes the whole favorite name. */
const TASK_TYPE_LABELS: Record<string, string> = {
AGENT: "Agent tasks",
STANDARD: "Standard tasks",
SCHEDULED: "Scheduled tasks",
};
/** "COMPLETED_SUCCESSFULLY" -> "Completed successfully", "history" -> "History". */
function humanizeValue(value: string): string {
const lowered = value.toLowerCase().replaceAll("_", " ");
return lowered.charAt(0).toUpperCase() + lowered.slice(1);
}
/** Pagination/UI-state params that never describe what the user filtered. */
const NON_FILTER_PARAMS = [FAVORITE_SEARCH_PARAM, ...PAGINATION_PARAMS, "span"];
/**
* Summarize a filtered view's search params into a short, selective descriptor for the favorite
* label ("Completed successfully, last 7d +2"). The best-known filters are named (at most two);
* everything else only counts toward a "+N" so heavily filtered views stay readable.
*/
function describeFilters(search: string): string | undefined {
const params = new URLSearchParams(search);
for (const param of NON_FILTER_PARAMS) {
params.delete(param);
}
const parts: string[] = [];
const consumed = new Set<string>();
const take = (key: string, describe: (values: string[]) => string | undefined) => {
const values = params.getAll(key).filter((value) => value.length > 0);
if (values.length === 0) return;
consumed.add(key);
const described = describe(values);
if (described) parts.push(described);
};
// Priority order: the filters most likely to identify the view come first
take("statuses", (v) => (v.length === 1 ? humanizeValue(v[0]) : `${v.length} statuses`));
take("levels", (v) => (v.length === 1 ? humanizeValue(v[0]) : `${v.length} levels`));
take("tasks", (v) => (v.length === 1 ? v[0] : `${v.length} tasks`));
take("queues", (v) => (v.length === 1 ? v[0].replace(/^task\//, "") : `${v.length} queues`));
take("tags", (v) => (v.length === 1 ? v[0] : `${v.length} tags`));
take("period", (v) => `last ${v[0]}`);
if (params.has("from") || params.has("to")) {
consumed.add("from");
consumed.add("to");
parts.push("custom range");
}
take("versions", (v) => (v.length === 1 ? v[0] : `${v.length} versions`));
take("machines", (v) => (v.length === 1 ? v[0] : `${v.length} machines`));
take("tab", (v) => humanizeValue(v[0]));
// The runs list appends rootOnly=false by default; only the non-default value is a filter
take("rootOnly", (v) => (v[0] === "true" ? "root only" : undefined));
const remaining = new Set([...params.keys()].filter((key) => !consumed.has(key))).size;
const MAX_NAMED_PARTS = 2;
const shown = parts.slice(0, MAX_NAMED_PARTS);
const extra = parts.length - shown.length + remaining;
if (shown.length === 0) {
return extra > 0 ? `${extra} filter${extra === 1 ? "" : "s"}` : undefined;
}
return shown.join(", ") + (extra > 0 ? ` +${extra}` : "");
}
/**
* Compose the default side menu label for a favorited page. Plain list pages keep their nav
* name ("Queues"); detail pages get an identifying prefix ("Queue: email-queue", or the short
* id for friendly-id pages: "Run: 05hrqq9n"); filtered views summarize their filters ("Runs:
* Completed successfully, last 7d"). Users can always rename.
*/
export function buildFavoriteLabel(
pathname: string,
search: string,
pageTitle: string | undefined
): string {
const meta = resolvePageMeta(pathname);
const title = pageTitle?.trim();
const prefix = meta.singular ?? meta.name;
// Generic titles ("Runs", "Run") identify nothing on their own; prefer ids/filters from the URL
const isGenericTitle =
!title ||
title.toLowerCase() === meta.name.toLowerCase() ||
title.toLowerCase() === prefix.toLowerCase();
if (isGenericTitle) {
// Named entity from the URL (task/agent slug) is the label on its own; its icon carries the type
if (meta.entityName) return truncateLabel(meta.entityName);
// The Tasks page filtered to a single task type takes that type as the whole name
if (meta.icon === "tasks") {
const types = new URLSearchParams(search).getAll("types");
if (types.length === 1 && TASK_TYPE_LABELS[types[0]]) {
return TASK_TYPE_LABELS[types[0]];
}
}
const detailId = detailIdFromPath(pathname);
if (detailId) return `${prefix}: ${detailId}`;
const filters = describeFilters(search);
return truncateLabel(filters ? `${meta.name}: ${filters}` : meta.name);
}
const label = title.toLowerCase().startsWith(prefix.toLowerCase())
? title
: `${prefix}: ${title}`;
return truncateLabel(label);
}
/**
* The user's favorited pages with any in-flight mutations applied, so the star button and the
* side menu section update instantly and stay in sync while the server round-trip completes.
*/
export function useFavorites(): FavoritePage[] {
const user = useOptionalUser();
const fetchers = useFetchers();
let favorites = user?.dashboardPreferences.sideMenu?.favorites ?? [];
for (const fetcher of fetchers) {
if (fetcher.formAction !== FAVORITES_ACTION_PATH || !fetcher.formData) continue;
const intent = fetcher.formData.get("intent");
const id = fetcher.formData.get("id");
if (typeof id !== "string") continue;
switch (intent) {
case "add": {
const url = fetcher.formData.get("url");
const label = fetcher.formData.get("label");
const icon = fetcher.formData.get("icon");
if (typeof url !== "string" || typeof label !== "string") break;
if (!favorites.some((f) => f.url === url)) {
// Newest favorites go to the top of the section (matches addFavorite server-side)
favorites = [
{ id, url, label, icon: typeof icon === "string" ? icon : undefined },
...favorites,
];
}
break;
}
case "remove": {
favorites = favorites.filter((f) => f.id !== id);
break;
}
case "rename": {
const label = fetcher.formData.get("label");
if (typeof label !== "string") break;
favorites = favorites.map((f) => (f.id === id ? { ...f, label } : f));
break;
}
}
}
return favorites;
}
@@ -2,6 +2,7 @@ import { z } from "zod";
// Valid section IDs that can have their collapsed state toggled
export const SideMenuSectionIdSchema = z.enum([
"favorites",
"ai",
"manage",
"metrics",
@@ -12,3 +13,58 @@ export const SideMenuSectionIdSchema = z.enum([
// Inferred type from the schema
export type SideMenuSectionId = z.infer<typeof SideMenuSectionIdSchema>;
// Size popover items to match the side-menu items, overriding the smaller small-menu-item
// defaults via tailwind-merge; icon carries the default dimmed color.
export const SIDE_MENU_POPOVER_ITEM_ICON = "h-5 w-5 text-text-dimmed";
export const SIDE_MENU_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]";
/** Default top-to-bottom order of the customizable side menu sections. */
export const DEFAULT_SECTION_ORDER: SideMenuSectionId[] = [
"favorites",
"ai",
"metrics",
"deployments",
"manage",
];
/**
* Order entries by a saved preference. Entries missing from the saved order (e.g. a section or
* item that shipped after the user customized) are inserted at their default position relative
* to the entries around them, not dumped at the end — so "Favorites" still lands above "AI" for
* users who saved an order before favorites existed.
*/
export function orderByPreference<T extends { id: string }>(
entries: T[],
savedOrder: string[] | undefined
): T[] {
if (!savedOrder || savedOrder.length === 0) return entries;
const defaultIndex = new Map(entries.map((entry, index) => [entry.id, index]));
// Set-dedupe: a corrupted saved order with duplicate ids must not render an entry twice
const orderedIds = [...new Set(savedOrder.filter((id) => defaultIndex.has(id)))];
const missingIds = entries.map((entry) => entry.id).filter((id) => !orderedIds.includes(id));
for (const id of missingIds) {
const idDefault = defaultIndex.get(id) ?? 0;
let insertAt = orderedIds.length;
for (let i = 0; i < orderedIds.length; i++) {
if ((defaultIndex.get(orderedIds[i]) ?? 0) > idDefault) {
insertAt = i;
break;
}
}
orderedIds.splice(insertAt, 0, id);
}
const byId = new Map(entries.map((entry) => [entry.id, entry]));
return orderedIds.map((id) => byId.get(id)!);
}
/** Effective hidden state for a menu item: the user's override wins, else the item's default. */
export function isItemHidden(
item: { id: string; defaultHidden?: boolean },
hiddenItems: Record<string, boolean> | undefined
): boolean {
return hiddenItems?.[item.id] ?? item.defaultHidden ?? false;
}
@@ -7,6 +7,7 @@ import { Header2 } from "./Headers";
import { LoadingBarDivider } from "./LoadingBarDivider";
import { SimpleTooltip } from "./Tooltip";
import { DashboardAgentLauncher } from "../dashboard-agent/dashboardAgentLauncher";
import { FavoritePageButton } from "../navigation/FavoritePageButton";
type WithChildren = {
children: React.ReactNode;
@@ -46,8 +47,10 @@ type PageTitleProps = {
};
export function PageTitle({ title, backButton, accessory }: PageTitleProps) {
const titleText = typeof title === "string" ? title : undefined;
return (
<div className="flex items-center gap-1">
<div className="flex items-center gap-1.5">
{backButton && (
<div className="group -ml-1.5 flex items-center gap-0">
<Link
@@ -60,17 +63,24 @@ export function PageTitle({ title, backButton, accessory }: PageTitleProps) {
</div>
)}
<Header2 className="flex items-center gap-1">{title}</Header2>
{accessory !== undefined &&
(typeof accessory === "string" ? (
<SimpleTooltip
button={<QuestionMarkIcon className="size-4 text-text-dimmed" />}
content={accessory}
className="max-w-xs"
disableHoverableContent
/>
) : (
accessory
))}
{accessory !== undefined && (
// ml-px optically evens the accessory against the title's tight text edge
<span className="ml-px flex items-center">
{typeof accessory === "string" ? (
<SimpleTooltip
button={<QuestionMarkIcon className="size-4 text-text-dimmed" />}
content={accessory}
className="max-w-xs"
disableHoverableContent
/>
) : (
accessory
)}
</span>
)}
{/* -ml-1 pulls the star's button box near-flush: its inner padding then provides the
visual gap, matching the title-to-accessory spacing while hovered */}
<FavoritePageButton pageTitle={titleText} className="-ml-1" />
</div>
);
}
@@ -48,7 +48,8 @@ import { findEnvironmentBySlug } from "~/models/runtimeEnvironment.server";
import { getTaskIdentifiers } from "~/models/task.server";
import { MetricDashboardPresenter } from "~/presenters/v3/MetricDashboardPresenter.server";
import { QueryPresenter } from "~/presenters/v3/QueryPresenter.server";
import { requireUser, requireUserId } from "~/services/session.server";
import { removeFavoritesByUrlSubstring } from "~/services/dashboardPreferences.server";
import { requireUser } from "~/services/session.server";
import {
EnvironmentParamSchema,
queryPath,
@@ -115,10 +116,10 @@ export const loader = async ({ request, params }: LoaderFunctionArgs) => {
};
export const action = async ({ request, params }: ActionFunctionArgs) => {
const userId = await requireUserId(request);
const user = await requireUser(request);
const { projectParam, organizationSlug, envParam, dashboardId } = ParamSchema.parse(params);
const project = await findProjectBySlug(organizationSlug, projectParam, userId);
const project = await findProjectBySlug(organizationSlug, projectParam, user.id);
if (!project) {
throw new Response("Project not found", { status: 404 });
}
@@ -144,6 +145,12 @@ export const action = async ({ request, params }: ActionFunctionArgs) => {
where: { id: dashboard.id },
});
// Drop any favorites pointing at this dashboard so the side menu doesn't keep a dead link
await removeFavoritesByUrlSubstring({
user,
substring: `/dashboards/custom/${dashboard.friendlyId}`,
});
return redirectWithSuccessMessage(
v3BuiltInDashboardPath(
{ slug: organizationSlug },
@@ -0,0 +1,80 @@
import { json, type ActionFunctionArgs } from "@remix-run/node";
import { z } from "zod";
import {
addFavorite,
removeFavorite,
renameFavorite,
} from "~/services/dashboardPreferences.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
const FavoriteLabel = z
.string()
.transform((value) => value.trim())
.pipe(z.string().min(1).max(64));
const RequestSchema = z.discriminatedUnion("intent", [
z.object({
intent: z.literal("add"),
id: z.string().min(1).max(64),
// App-relative URL only ("/..." but not protocol-relative "//...")
url: z
.string()
.min(1)
.max(2048)
.refine((url) => url.startsWith("/") && !url.startsWith("//"), {
message: "URL must be app-relative",
}),
label: FavoriteLabel,
icon: z
.string()
.regex(/^[a-z0-9-]+$/)
.max(64)
.optional(),
}),
z.object({
intent: z.literal("remove"),
id: z.string().min(1).max(64),
}),
z.object({
intent: z.literal("rename"),
id: z.string().min(1).max(64),
label: FavoriteLabel,
}),
]);
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request);
const formData = await request.formData();
const result = RequestSchema.safeParse(Object.fromEntries(formData));
if (!result.success) {
return json({ success: false, error: "Invalid request data" }, { status: 400 });
}
// Errors come back as a response (never a throw, which would escalate a preferences write to
// the error boundary); the side menu's optimistic entries revert when the fetcher settles.
try {
switch (result.data.intent) {
case "add": {
const { id, url, label, icon } = result.data;
await addFavorite({ user, favorite: { id, url, label, icon } });
break;
}
case "remove": {
await removeFavorite({ user, id: result.data.id });
break;
}
case "rename": {
await renameFavorite({ user, id: result.data.id, label: result.data.label });
break;
}
}
} catch (error) {
logger.error("Failed to update favorites", { error: String(error) });
return json({ success: false, error: "Failed to save preferences" }, { status: 500 });
}
return json({ success: true });
}
@@ -4,7 +4,12 @@ import {
SideMenuSectionIdSchema,
type SideMenuSectionId,
} from "~/components/navigation/sideMenuTypes";
import { updateItemOrder, updateSideMenuPreferences } from "~/services/dashboardPreferences.server";
import {
updateItemOrder,
updateSideMenuCustomization,
updateSideMenuPreferences,
} from "~/services/dashboardPreferences.server";
import { logger } from "~/services/logger.server";
import { requireUser } from "~/services/session.server";
// Transforms form data string "true"/"false" to boolean, or undefined if not present
@@ -22,11 +27,32 @@ const RequestSchema = z.object({
organizationId: z.string().optional(),
listId: z.string().optional(),
itemOrder: z.string().optional(), // JSON-encoded string[]
customization: z.string().optional(), // JSON-encoded CustomizationSchema
});
// Payload of the "Customize sidebar" modal. For the nullable fields, null resets to default and
// an absent field leaves the stored value unchanged.
const CustomizationSchema = z.object({
sectionOrder: z.array(z.string().max(64)).max(50).nullish(),
hiddenItems: z.record(z.string().max(64), z.boolean()).nullish(),
sectionItemOrder: z.record(z.string().max(64), z.array(z.string().max(64)).max(100)).nullish(),
favorites: z
.array(z.object({ id: z.string().max(64), label: z.string().max(64) }))
.max(100)
.optional(),
removedFavoriteIds: z.array(z.string().max(64)).max(100).optional(),
});
export async function action({ request }: ActionFunctionArgs) {
const user = await requireUser(request);
// Every writer below deliberately skips impersonated sessions so an admin's browsing can't
// rewrite the customer's saved layout. That skip is a no-op, not a failed save, so report it as
// success: it must not reach the callers that surface write failures to the user.
if (user.isImpersonating) {
return json({ success: true });
}
const formData = await request.formData();
const rawData = Object.fromEntries(formData);
@@ -35,6 +61,42 @@ export async function action({ request }: ActionFunctionArgs) {
return json({ success: false, error: "Invalid request data" }, { status: 400 });
}
// Handle a "Customize sidebar" modal submit
if (result.data.customization) {
let parsed: unknown;
try {
parsed = JSON.parse(result.data.customization);
} catch {
parsed = null;
}
const customizationResult = CustomizationSchema.safeParse(parsed);
if (!customizationResult.success) {
return json({ success: false, error: "Invalid request data" }, { status: 400 });
}
const { sectionOrder, hiddenItems, sectionItemOrder, favorites, removedFavoriteIds } =
customizationResult.data;
// The modal keeps its "Confirm" pending until this responds, so failures must come back as a
// response (never a throw, which would escalate a preferences write to the error boundary).
try {
const updated = await updateSideMenuCustomization({
user,
sectionOrder,
hiddenItems,
sectionItemOrder,
favorites,
removedFavoriteIds,
});
// undefined means nothing was written (impersonating, or the user row is gone)
if (!updated) {
return json({ success: false, error: "Failed to save preferences" }, { status: 500 });
}
} catch (error) {
logger.error("Failed to save sidebar customization", { error: String(error) });
return json({ success: false, error: "Failed to save preferences" }, { status: 500 });
}
return json({ success: true });
}
// Handle item order update
if (result.data.organizationId && result.data.listId && result.data.itemOrder) {
let parsed: unknown;
@@ -1,8 +1,21 @@
import { z } from "zod";
import { prisma } from "~/db.server";
import { $transaction, prisma } from "~/db.server";
import { logger } from "./logger.server";
import { type UserFromSession } from "./session.server";
const FavoritePage = z.object({
/** Stable id, generated client-side when the page is favorited. */
id: z.string(),
/** App-relative URL including any search params (filters, tabs). */
url: z.string(),
/** Display label shown in the side menu; user-renamable. */
label: z.string(),
/** Key into the favorite page icon registry. */
icon: z.string().optional(),
});
export type FavoritePage = z.infer<typeof FavoritePage>;
const SideMenuPreferences = z.object({
isCollapsed: z.boolean().default(false),
/** Expanded side menu width in px, set by the resize handle. */
@@ -18,6 +31,14 @@ const SideMenuPreferences = z.object({
})
)
.optional(),
/** Pages the user favorited, in display order. */
favorites: z.array(FavoritePage).optional(),
/** Custom top-to-bottom order of side menu sections (section ids). */
sectionOrder: z.array(z.string()).optional(),
/** Per-item visibility overrides (item id -> hidden). Items absent fall back to their default. */
hiddenItems: z.record(z.string(), z.boolean()).optional(),
/** Custom item order within a section (section id -> item ids). */
sectionItemOrder: z.record(z.string(), z.array(z.string())).optional(),
});
export type SideMenuPreferences = z.infer<typeof SideMenuPreferences>;
@@ -59,6 +80,51 @@ export function getDashboardPreferences(data?: any | null): DashboardPreferences
return result.data;
}
/**
* Every preference writer is a read-modify-write over one JSON column, and several fire
* concurrently (debounced collapse/width, favorite toggles, the customize modal, dashboard
* reorders). Each write re-reads the row under a FOR UPDATE lock so concurrent writers
* serialize instead of clobbering each other's fields with stale reads — without the lock, a
* debounced collapse write could resurrect customizations the modal's Reset just cleared.
*
* Return undefined from `mutate` to skip the write (no-op update).
*/
async function mutateDashboardPreferences(
userId: string,
mutate: (current: DashboardPreferences) => DashboardPreferences | undefined
) {
return await $transaction(
prisma,
"mutateDashboardPreferences",
async (tx) => {
const rows = await tx.$queryRaw<Array<{ dashboardPreferences: unknown }>>`
SELECT "dashboardPreferences" FROM "User" WHERE id = ${userId} FOR UPDATE
`;
if (rows.length === 0) {
return undefined;
}
const updated = mutate(getDashboardPreferences(rows[0].dashboardPreferences));
if (!updated) {
return undefined;
}
return await tx.user.update({
where: {
id: userId,
},
data: {
dashboardPreferences: updated,
},
});
},
// Concurrent writers queue on the row lock, so under load (several debounced writes plus a
// revalidation burst) a transaction can time out acquiring a connection or the lock; those
// codes are retriable and preference writes are idempotent.
{ maxRetries: 3 }
);
}
export async function updateCurrentProjectEnvironmentId({
user,
projectId,
@@ -72,7 +138,9 @@ export async function updateCurrentProjectEnvironmentId({
return;
}
//only update if the existing preferences are different
// Fast path: this runs on nearly every navigation (env layout loader), so skip the locked
// transaction when the session snapshot already matches. The in-transaction check below stays
// authoritative for the rare stale-snapshot case.
if (
user.dashboardPreferences.currentProjectId === projectId &&
user.dashboardPreferences.projects[projectId]?.currentEnvironment?.id === environmentId
@@ -80,26 +148,26 @@ export async function updateCurrentProjectEnvironmentId({
return;
}
//ok we need to update the preferences
const updatedPreferences: DashboardPreferences = {
...user.dashboardPreferences,
currentProjectId: projectId,
projects: {
...user.dashboardPreferences.projects,
[projectId]: {
...user.dashboardPreferences.projects[projectId],
currentEnvironment: { id: environmentId },
},
},
};
return mutateDashboardPreferences(user.id, (prefs) => {
//only update if the existing preferences are different
if (
prefs.currentProjectId === projectId &&
prefs.projects[projectId]?.currentEnvironment?.id === environmentId
) {
return undefined;
}
return prisma.user.update({
where: {
id: user.id,
},
data: {
dashboardPreferences: updatedPreferences,
},
return {
...prefs,
currentProjectId: projectId,
projects: {
...prefs.projects,
[projectId]: {
...prefs.projects[projectId],
currentEnvironment: { id: environmentId },
},
},
};
});
}
@@ -108,19 +176,10 @@ export async function clearCurrentProject({ user }: { user: UserFromSession }) {
return;
}
const updatedPreferences: DashboardPreferences = {
...user.dashboardPreferences,
return mutateDashboardPreferences(user.id, (prefs) => ({
...prefs,
currentProjectId: undefined,
};
return prisma.user.update({
where: {
id: user.id,
},
data: {
dashboardPreferences: updatedPreferences,
},
});
}));
}
export async function updateSideMenuPreferences({
@@ -140,48 +199,234 @@ export async function updateSideMenuPreferences({
return;
}
// Parse with schema to apply defaults, then overlay any new values
const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {});
return mutateDashboardPreferences(user.id, (prefs) => {
// Parse with schema to apply defaults, then overlay any new values
const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {});
// Build the updated collapsedSections map
let updatedCollapsedSections = { ...currentSideMenu.collapsedSections };
// Build the updated collapsedSections map
let updatedCollapsedSections = { ...currentSideMenu.collapsedSections };
if (sectionCollapsed) {
updatedCollapsedSections[sectionCollapsed.sectionId] = sectionCollapsed.collapsed;
}
if (sectionCollapsed) {
updatedCollapsedSections[sectionCollapsed.sectionId] = sectionCollapsed.collapsed;
}
const updatedSideMenu = SideMenuPreferences.parse({
...currentSideMenu,
...(isCollapsed !== undefined && { isCollapsed }),
...(width !== undefined && { width }),
collapsedSections: updatedCollapsedSections,
const updatedSideMenu = SideMenuPreferences.parse({
...currentSideMenu,
...(isCollapsed !== undefined && { isCollapsed }),
...(width !== undefined && { width }),
collapsedSections: updatedCollapsedSections,
});
// Only update if something changed
const hasCollapsedSectionsChanged =
JSON.stringify(updatedSideMenu.collapsedSections) !==
JSON.stringify(currentSideMenu.collapsedSections);
if (
updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed &&
updatedSideMenu.width === currentSideMenu.width &&
!hasCollapsedSectionsChanged
) {
return undefined;
}
return { ...prefs, sideMenu: updatedSideMenu };
});
}
// Only update if something changed
const hasCollapsedSectionsChanged =
JSON.stringify(updatedSideMenu.collapsedSections) !==
JSON.stringify(currentSideMenu.collapsedSections);
/** The most favorites a user can save; a sanity cap, not a product limit. */
const MAX_FAVORITES = 50;
if (
updatedSideMenu.isCollapsed === currentSideMenu.isCollapsed &&
updatedSideMenu.width === currentSideMenu.width &&
!hasCollapsedSectionsChanged
) {
export async function addFavorite({
user,
favorite,
}: {
user: UserFromSession;
favorite: FavoritePage;
}) {
if (user.isImpersonating) {
return;
}
const updatedPreferences: DashboardPreferences = {
...user.dashboardPreferences,
sideMenu: updatedSideMenu,
};
return mutateDashboardPreferences(user.id, (prefs) => {
const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {});
const favorites = currentSideMenu.favorites ?? [];
return prisma.user.update({
where: {
id: user.id,
},
data: {
dashboardPreferences: updatedPreferences,
},
// The star is a toggle keyed on the exact URL, so an existing entry means we're already done
if (favorites.some((f) => f.url === favorite.url)) {
return undefined;
}
if (favorites.length >= MAX_FAVORITES) {
return undefined;
}
// Newest favorites go to the top of the section
return {
...prefs,
sideMenu: { ...currentSideMenu, favorites: [favorite, ...favorites] },
};
});
}
export async function removeFavorite({ user, id }: { user: UserFromSession; id: string }) {
if (user.isImpersonating) {
return;
}
return mutateDashboardPreferences(user.id, (prefs) => {
const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {});
const favorites = currentSideMenu.favorites ?? [];
const remaining = favorites.filter((f) => f.id !== id);
if (remaining.length === favorites.length) {
return undefined;
}
return {
...prefs,
sideMenu: {
...currentSideMenu,
favorites: remaining.length > 0 ? remaining : undefined,
},
};
});
}
/**
* Remove any favorites whose URL contains the given substring. Used when the favorited entity
* itself is deleted (e.g. a custom dashboard's friendly id) so the side menu doesn't keep a
* dead link.
*/
export async function removeFavoritesByUrlSubstring({
user,
substring,
}: {
user: UserFromSession;
substring: string;
}) {
if (user.isImpersonating) {
return;
}
return mutateDashboardPreferences(user.id, (prefs) => {
const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {});
const favorites = currentSideMenu.favorites ?? [];
const remaining = favorites.filter((favorite) => !favorite.url.includes(substring));
if (remaining.length === favorites.length) {
return undefined;
}
return {
...prefs,
sideMenu: {
...currentSideMenu,
favorites: remaining.length > 0 ? remaining : undefined,
},
};
});
}
export async function renameFavorite({
user,
id,
label,
}: {
user: UserFromSession;
id: string;
label: string;
}) {
if (user.isImpersonating) {
return;
}
return mutateDashboardPreferences(user.id, (prefs) => {
const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {});
const favorites = currentSideMenu.favorites ?? [];
const favorite = favorites.find((f) => f.id === id);
if (!favorite || favorite.label === label) {
return undefined;
}
return {
...prefs,
sideMenu: {
...currentSideMenu,
favorites: favorites.map((f) => (f.id === id ? { ...f, label } : f)),
},
};
});
}
export async function updateSideMenuCustomization({
user,
sectionOrder,
hiddenItems,
sectionItemOrder,
favorites,
removedFavoriteIds,
}: {
user: UserFromSession;
/** undefined = leave unchanged, null = reset to default */
sectionOrder?: string[] | null;
/** undefined = leave unchanged, null = reset to default */
hiddenItems?: Record<string, boolean> | null;
/** undefined = leave unchanged, null = reset to default */
sectionItemOrder?: Record<string, string[]> | null;
/** Full favorites arrangement: new order + labels. undefined = leave unchanged. */
favorites?: Array<{ id: string; label: string }>;
/** Favorites deleted from the customize modal. */
removedFavoriteIds?: string[];
}) {
if (user.isImpersonating) {
return;
}
return mutateDashboardPreferences(user.id, (prefs) => {
const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {});
const next: SideMenuPreferences = { ...currentSideMenu };
if (sectionOrder !== undefined) {
next.sectionOrder = sectionOrder && sectionOrder.length > 0 ? sectionOrder : undefined;
}
if (hiddenItems !== undefined) {
next.hiddenItems =
hiddenItems && Object.keys(hiddenItems).length > 0 ? hiddenItems : undefined;
}
if (sectionItemOrder !== undefined) {
next.sectionItemOrder =
sectionItemOrder && Object.keys(sectionItemOrder).length > 0 ? sectionItemOrder : undefined;
}
if (favorites !== undefined || removedFavoriteIds !== undefined) {
const removed = new Set(removedFavoriteIds ?? []);
const current = (currentSideMenu.favorites ?? []).filter((f) => !removed.has(f.id));
const byId = new Map(current.map((f) => [f.id, f]));
const rearranged: FavoritePage[] = [];
for (const { id, label } of favorites ?? []) {
const existing = byId.get(id);
if (!existing) continue;
const trimmed = label.trim();
rearranged.push({ ...existing, label: trimmed.length > 0 ? trimmed : existing.label });
byId.delete(id);
}
// Favorites the payload didn't mention (e.g. added mid-edit) keep their place at the end
for (const favorite of current) {
if (byId.has(favorite.id)) {
rearranged.push(favorite);
}
}
next.favorites = rearranged.length > 0 ? rearranged : undefined;
}
return { ...prefs, sideMenu: SideMenuPreferences.parse(next) };
});
}
@@ -209,34 +454,24 @@ export async function updateItemOrder({
return;
}
const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {});
const currentOrg = currentSideMenu.organizations?.[organizationId];
return mutateDashboardPreferences(user.id, (prefs) => {
const currentSideMenu = SideMenuPreferences.parse(prefs.sideMenu ?? {});
const currentOrg = currentSideMenu.organizations?.[organizationId];
const updatedSideMenu = SideMenuPreferences.parse({
...currentSideMenu,
organizations: {
...currentSideMenu.organizations,
[organizationId]: {
...currentOrg,
orderedItems: {
...currentOrg?.orderedItems,
[listId]: order,
const updatedSideMenu = SideMenuPreferences.parse({
...currentSideMenu,
organizations: {
...currentSideMenu.organizations,
[organizationId]: {
...currentOrg,
orderedItems: {
...currentOrg?.orderedItems,
[listId]: order,
},
},
},
},
});
});
const updatedPreferences: DashboardPreferences = {
...user.dashboardPreferences,
sideMenu: updatedSideMenu,
};
return prisma.user.update({
where: {
id: user.id,
},
data: {
dashboardPreferences: updatedPreferences,
},
return { ...prefs, sideMenu: updatedSideMenu };
});
}