From ca66dd7a0c207575851e17613e9b5658920d6382 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sat, 25 Jul 2026 09:22:26 +0100 Subject: [PATCH 01/18] feat(webapp): favorite pages and sidebar customization Add a star button to every page header (and Option+F) that favorites the current page, full URL included, into a new Favorites side menu section that appears instantly, with hover Rename (inline edit) and Remove. Add a Customize sidebar modal on section header ellipsis menus: reorder sections with arrows, drag-reorder items, hide items behind a per-section More popover via eye toggles, and rename favorites inline. Changes apply on Confirm; Reset restores the default layout without touching favorites. Preferences are stored per user in dashboard preferences. --- ...avorite-pages-and-sidebar-customization.md | 6 + apps/webapp/app/components/Shortcuts.tsx | 4 + .../navigation/CustomizeSidebarDialog.tsx | 402 +++++++++ .../navigation/FavoritePageButton.tsx | 136 +++ .../navigation/FavoritesSection.tsx | 181 ++++ .../app/components/navigation/SideMenu.tsx | 774 ++++++++++++------ .../components/navigation/SideMenuItem.tsx | 5 +- .../components/navigation/SideMenuSection.tsx | 21 +- .../components/navigation/favoritePages.tsx | 255 ++++++ .../components/navigation/sideMenuTypes.ts | 50 ++ .../app/components/primitives/PageHeader.tsx | 26 +- .../resources.preferences.favorites.tsx | 72 ++ .../routes/resources.preferences.sidemenu.tsx | 42 +- .../services/dashboardPreferences.server.ts | 175 ++++ 14 files changed, 1908 insertions(+), 241 deletions(-) create mode 100644 .server-changes/favorite-pages-and-sidebar-customization.md create mode 100644 apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx create mode 100644 apps/webapp/app/components/navigation/FavoritePageButton.tsx create mode 100644 apps/webapp/app/components/navigation/FavoritesSection.tsx create mode 100644 apps/webapp/app/components/navigation/favoritePages.tsx create mode 100644 apps/webapp/app/routes/resources.preferences.favorites.tsx diff --git a/.server-changes/favorite-pages-and-sidebar-customization.md b/.server-changes/favorite-pages-and-sidebar-customization.md new file mode 100644 index 00000000000..d4468b4df51 --- /dev/null +++ b/.server-changes/favorite-pages-and-sidebar-customization.md @@ -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. diff --git a/apps/webapp/app/components/Shortcuts.tsx b/apps/webapp/app/components/Shortcuts.tsx index 87e5d08f371..c4ce2db6d0f 100644 --- a/apps/webapp/app/components/Shortcuts.tsx +++ b/apps/webapp/app/components/Shortcuts.tsx @@ -73,6 +73,10 @@ function ShortcutContent() { + + + + diff --git a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx new file mode 100644 index 00000000000..d96b8a84fa9 --- /dev/null +++ b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx @@ -0,0 +1,402 @@ +import { DialogClose } from "@radix-ui/react-dialog"; +import { ArrowDownIcon, ArrowUpIcon, EyeIcon, EyeSlashIcon } from "@heroicons/react/20/solid"; +import { GripVerticalIcon } from "lucide-react"; +import { useState } from "react"; +import ReactGridLayout, { type Layout, useContainerWidth } from "react-grid-layout"; +import { cn } from "~/utils/cn"; +import { Button } from "../primitives/Buttons"; +import { DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog"; +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; + 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; + sectionItemOrder?: Record; +}; + +/** What Confirm produces; null clears a stored preference back to its default. */ +export type SidebarCustomizationPayload = { + sectionOrder: string[] | null; + hiddenItems: Record | null; + sectionItemOrder: Record | null; + favorites?: Array<{ id: string; label: string }>; +}; + +type DialogState = { + sectionOrder: string[]; + /** section id -> item ids in order */ + itemOrders: Record; + /** item id -> effective hidden */ + hidden: Record; + /** favorite id -> label being edited */ + labels: Record; +}; + +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 = {}; + const hidden: Record = {}; + const labels: Record = {}; + + 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 }; +} + +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, + onClose, +}: { + sections: CustomizeSidebarSection[]; + prefs: SavedPreferences | undefined; + /** Owned by the parent: closing this dialog unmounts it, so it can't run its own fetcher. */ + onConfirm: (payload: SidebarCustomizationPayload) => void; + onClose: () => void; +}) { + const [state, setState] = useState(() => buildState(sections, prefs)); + + const orderedSections = state.sectionOrder + .map((id) => sections.find((section) => section.id === id)) + .filter((section): section is CustomizeSidebarSection => section !== undefined); + + const moveSection = (index: number, direction: -1 | 1) => { + setState((current) => { + const next = [...current.sectionOrder]; + const target = index + direction; + if (target < 0 || target >= next.length) return current; + [next[index], next[target]] = [next[target], next[index]]; + 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 reset = () => setState(buildState(sections, undefined)); + + const confirm = () => { + const defaults = buildState(sections, undefined); + + const hiddenOverrides: Record = {}; + for (const section of sections) { + for (const item of section.items) { + const isHidden = state.hidden[item.id] ?? false; + if (isHidden !== (item.defaultHidden ?? false)) { + hiddenOverrides[item.id] = isHidden; + } + } + } + + const sectionItemOrder: Record = {}; + 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] ?? []; + const favoritesChanged = + favoritesSection !== undefined && + (!arraysEqual(favoriteOrder, defaults.itemOrders[FAVORITES_SECTION_ID] ?? []) || + favoritesSection.items.some( + (item) => (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, + }; + + onConfirm(payload); + onClose(); + }; + + return ( + + Customize sidebar +
+ {orderedSections.map((section, index) => ( +
+
+ {section.title} +
+ moveSection(index, -1)} + > + + + moveSection(index, 1)} + > + + +
+
+ item.id)} + hidden={state.hidden} + labels={state.labels} + onReorder={(itemIds) => reorderItems(section.id, itemIds)} + onToggleHidden={toggleHidden} + onLabelChange={setLabel} + /> +
+ ))} +
+ +
+ + + + +
+ +
+
+ ); +} + +function SectionMoveButton({ + label, + disabled, + onClick, + children, +}: { + label: string; + disabled: boolean; + onClick: () => void; + children: React.ReactNode; +}) { + return ( + + ); +} + +function SectionItemList({ + section, + order, + hidden, + labels, + onReorder, + onToggleHidden, + onLabelChange, +}: { + section: CustomizeSidebarSection; + order: string[]; + hidden: Record; + labels: Record; + onReorder: (itemIds: string[]) => void; + onToggleHidden: (itemId: string) => void; + onLabelChange: (itemId: string, label: 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 }) => ( + onToggleHidden(item.id)} + onLabelChange={(label) => onLabelChange(item.id, label)} + /> + ); + + return ( +
}> + {items.length >= 2 ? ( + + {items.map((item) => ( +
{renderRow(item, { draggable: true })}
+ ))} +
+ ) : ( + items.map((item) =>
{renderRow(item, { draggable: false })}
) + )} +
+ ); +} + +function ModalItemRow({ + item, + isHidden, + label, + draggable, + onToggleHidden, + onLabelChange, +}: { + item: CustomizeSidebarItem; + isHidden: boolean; + label: string | undefined; + draggable: boolean; + onToggleHidden: () => void; + onLabelChange: (label: string) => void; +}) { + return ( +
+
+ + {item.isFavorite ? ( + onLabelChange(e.target.value)} + variant="small" + maxLength={64} + containerClassName="max-w-60" + aria-label={`Rename ${item.name}`} + /> + ) : ( + {item.name} + )} +
+
+ + {draggable ? ( +
+ +
+ ) : ( +
+ )} +
+
+ ); +} diff --git a/apps/webapp/app/components/navigation/FavoritePageButton.tsx b/apps/webapp/app/components/navigation/FavoritePageButton.tsx new file mode 100644 index 00000000000..34a4f2c84b3 --- /dev/null +++ b/apps/webapp/app/components/navigation/FavoritePageButton.tsx @@ -0,0 +1,136 @@ +import { StarIcon as StarIconOutline } from "@heroicons/react/24/outline"; +import { StarIcon as StarIconSolid } from "@heroicons/react/20/solid"; +import { useFetcher, useLocation } from "@remix-run/react"; +import { useEffect, useRef } from "react"; +import { useIsImpersonating } from "~/hooks/useOrganizations"; +import { useOptionalUser } from "~/hooks/useUser"; +import { Button } from "../primitives/Buttons"; +import { ShortcutKey } from "../primitives/ShortcutKey"; +import { useShortcuts } from "../primitives/ShortcutsProvider"; +import { SimpleTooltip } from "../primitives/Tooltip"; +import { + buildFavoriteLabel, + FAVORITES_ACTION_PATH, + 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 }: { pageTitle?: string }) { + const user = useOptionalUser(); + const isImpersonating = useIsImpersonating(); + const location = useLocation(); + const favorites = useFavorites(); + const fetcher = useFetcher(); + const { areShortcutsEnabled } = useShortcuts(); + + const url = location.pathname + location.search; + const existing = favorites.find((favorite) => favorite.url === url); + const isFavorited = existing !== undefined; + const pageName = pageTitle?.trim() || resolvePageMeta(location.pathname).name; + + 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, pageTitle), + icon: resolvePageMeta(location.pathname).icon, + }, + { method: "POST", action: FAVORITES_ACTION_PATH } + ); + } + }; + + // The listener reads the latest toggle through a ref so it isn't re-attached every render + const toggleRef = useRef(toggle); + toggleRef.current = toggle; + + const showButton = user !== undefined && !isImpersonating; + + useEffect(() => { + if (!showButton || !areShortcutsEnabled) return; + + const onKeyDown = (event: KeyboardEvent) => { + // Matched on `event.code`: on macOS, Option makes "F" report "ƒ" via `event.key`, so the + // event.key-based useShortcutKeys hook can't capture Option+letter (see GlobalShortcuts). + if ( + event.code !== "KeyF" || + !event.altKey || + event.metaKey || + event.ctrlKey || + event.shiftKey + ) { + return; + } + const target = event.target; + if ( + target instanceof HTMLElement && + (target.tagName === "INPUT" || + target.tagName === "TEXTAREA" || + target.tagName === "SELECT" || + target.isContentEditable) + ) { + return; + } + event.preventDefault(); + toggleRef.current(); + }; + + document.addEventListener("keydown", onKeyDown); + return () => document.removeEventListener("keydown", onKeyDown); + }, [showButton, areShortcutsEnabled]); + + if (!showButton) { + return null; + } + + const tooltipLabel = isFavorited + ? `Remove ${pageName} from favorites` + : `Add ${pageName} to favorites`; + + return ( + +
@@ -1097,10 +1190,219 @@ export function SideMenu({
+ + {/* Mounted only while open so the modal state re-seeds from current preferences each time */} + {isCustomizeOpen && ( + setCustomizeOpen(false)} + /> + )} + ); } +/** A section built from {@link SideMenuItemConfig}s with the user's order/hidden prefs applied. */ +function CustomizableSideMenuSection({ + section, + itemOrder, + hiddenItems, + isCollapsed, + initialCollapsed, + onCollapseToggle, + headerMenu, +}: { + section: SideMenuSectionConfig; + itemOrder: string[] | undefined; + hiddenItems: Record | undefined; + isCollapsed: boolean; + initialCollapsed: boolean; + onCollapseToggle: (collapsed: boolean) => void; + headerMenu: ReactNode; +}) { + const orderedItems = orderByPreference(section.items, itemOrder); + const visibleItems = orderedItems.filter((item) => !isItemHidden(item, hiddenItems)); + const moreItems = orderedItems.filter((item) => isItemHidden(item, hiddenItems)); + + return ( + + {visibleItems.map((item) => ( + + + {item.after} + + ))} + {moreItems.length > 0 && ( + ({ + id: item.id, + name: item.name, + icon: item.icon, + to: item.to, + }))} + isCollapsed={isCollapsed} + /> + )} + + ); +} + +/** The user's favorited pages; hidden favorites collapse into the trailing "More" item. */ +function FavoritesSideMenuSection({ + favorites, + hiddenItems, + isCollapsed, + initialCollapsed, + onCollapseToggle, + headerMenu, +}: { + favorites: FavoritePage[]; + hiddenItems: Record | undefined; + isCollapsed: boolean; + initialCollapsed: boolean; + onCollapseToggle: (collapsed: boolean) => void; + headerMenu: ReactNode; +}) { + const visible = favorites.filter((favorite) => !(hiddenItems?.[favorite.id] ?? false)); + const hidden = favorites.filter((favorite) => hiddenItems?.[favorite.id] ?? false); + + return ( + + {visible.map((favorite) => ( + + ))} + {hidden.length > 0 && ( + ({ + id: favorite.id, + name: favorite.label, + icon: favoritePageIcon(favorite.icon), + to: favorite.url, + }))} + isCollapsed={isCollapsed} + /> + )} + + ); +} + +/** + * The trailing "More" item of a section: a popover listing that section's hidden items. Only + * rendered when the section has hidden items. + */ +function SideMenuMoreItem({ + items, + isCollapsed, +}: { + items: Array<{ id: string; name: string; icon: RenderIcon; to: string }>; + isCollapsed: boolean; +}) { + const [isOpen, setOpen] = useState(false); + const navigation = useNavigation(); + + useEffect(() => { + setOpen(false); + }, [navigation.location?.pathname]); + + return ( + + + + + More + + + } + content="More" + side="right" + sideOffset={8} + hidden={!isCollapsed} + asChild + tabbable + disableHoverableContent + /> + +
+ {items.map((item) => ( + + ))} +
+
+
+ ); +} + +/** The ellipsis on section headers (visible on hover) opening the sidebar customization menu. */ +function SectionHeaderMenu({ onCustomize }: { onCustomize: () => void }) { + const [isOpen, setOpen] = useState(false); + + return ( + + + + + + { + setOpen(false); + onCustomize(); + }} + /> + + + ); +} + function V3DeprecationPanel({ isCollapsed, isV3, diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index f17051266d4..1f763f513e7 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -27,6 +27,7 @@ export function SideMenuItem({ action, disableIconHover = false, indented = false, + isActive: isActiveOverride, "data-action": dataAction, }: { icon?: RenderIcon; @@ -45,10 +46,12 @@ 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; "data-action"?: string; }) { const pathName = usePathName(); - const isActive = pathName === to; + const isActive = isActiveOverride ?? pathName === to; const isIndented = indented && !isCollapsed; diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx index 8225d9e701c..8cb3c3ef0e0 100644 --- a/apps/webapp/app/components/navigation/SideMenuSection.tsx +++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx @@ -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(null); @@ -45,7 +51,7 @@ export function SideMenuSection({ return (
{/* Header container - stays in DOM to preserve height */} -
+
{/* 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. @@ -75,6 +81,19 @@ export function SideMenuSection({
{headerAction &&
{headerAction}
} + {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). +
+
+ {headerMenu} +
+
+ )} {/* Divider fades in via --sm-collapse (0 → 1) as the header fades out. Only while expanded. */} diff --git a/apps/webapp/app/components/navigation/favoritePages.tsx b/apps/webapp/app/components/navigation/favoritePages.tsx new file mode 100644 index 00000000000..6cbffa8b2c5 --- /dev/null +++ b/apps/webapp/app/components/navigation/favoritePages.tsx @@ -0,0 +1,255 @@ +import { BeakerIcon, ClockIcon } from "@heroicons/react/24/outline"; +import { useFetchers } from "@remix-run/react"; +import { AIChatIcon } from "~/assets/icons/AIChatIcon"; +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 { 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 { 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"; + +/** + * Icons a favorited page can be saved with, keyed by a stable string so preferences never store + * component references. Unknown keys fall back to the star. + */ +const FAVORITE_PAGE_ICONS: Record = { + tasks: TasksIcon, + runs: RunsIcon, + sessions: AIChatIcon, + prompts: AIPenIcon, + models: Box3DIcon, + logs: LogsIcon, + errors: BugIcon, + query: CodeSquareIcon, + queues: QueuesIcon, + dashboards: ChartBarIcon, + deployments: DeploymentsIcon, + "environment-variables": IDIcon, + branches: BranchEnvironmentIconSmall, + regions: GlobeLinesIcon, + waitpoints: WaitpointTokenIcon, + batches: BatchesIcon, + "bulk-actions": ListCheckedIcon, + apikeys: KeyIcon, + alerts: BellIcon, + concurrency: ConcurrencyIcon, + limits: DialIcon, + schedules: ClockIcon, + test: BeakerIcon, + "project-settings": SlidersIcon, + integrations: IntegrationsIcon, + slack: SlackIcon, + project: FolderOpenIcon, + "org-settings": SlidersIcon, + team: UserGroupIcon, + billing: CreditCardIcon, + usage: UsageIcon, + roles: RolesIcon, + sso: PadlockIcon, + "private-connections": ChainLinkIcon, + account: AvatarCircleIcon, + tokens: ShieldIcon, + security: PadlockIcon, + page: StarIcon, +}; + +export function favoritePageIcon(iconKey: string | undefined): RenderIcon { + return (iconKey ? FAVORITE_PAGE_ICONS[iconKey] : undefined) ?? StarIcon; +} + +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; +}; + +const ENV_PAGE_META: Record = { + "": { 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" }, +}; + +const ORG_SETTINGS_PAGE_META: Record = { + "": { 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 = { + "": { 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" }; + } + 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: "integrations", 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; + +/** + * Compose the default side menu label for a favorited page. List pages keep their nav name + * ("Queues"); detail pages get an identifying prefix ("Queue: email-queue"). Users can rename. + */ +export function buildFavoriteLabel(pathname: string, pageTitle: string | undefined): string { + const meta = resolvePageMeta(pathname); + const title = pageTitle?.trim(); + + if (!title || title.toLowerCase() === meta.name.toLowerCase()) { + return meta.name; + } + + const prefix = meta.singular ?? meta.name; + const label = title.toLowerCase().startsWith(prefix.toLowerCase()) + ? title + : `${prefix}: ${title}`; + return label.length > MAX_LABEL_LENGTH ? `${label.slice(0, MAX_LABEL_LENGTH - 1)}…` : 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)) { + favorites = [ + ...favorites, + { id, url, label, icon: typeof icon === "string" ? icon : undefined }, + ]; + } + 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; +} diff --git a/apps/webapp/app/components/navigation/sideMenuTypes.ts b/apps/webapp/app/components/navigation/sideMenuTypes.ts index bb245f4bcb3..b5d32456e84 100644 --- a/apps/webapp/app/components/navigation/sideMenuTypes.ts +++ b/apps/webapp/app/components/navigation/sideMenuTypes.ts @@ -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,52 @@ export const SideMenuSectionIdSchema = z.enum([ // Inferred type from the schema export type SideMenuSectionId = z.infer; + +/** 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( + entries: T[], + savedOrder: string[] | undefined +): T[] { + if (!savedOrder || savedOrder.length === 0) return entries; + + const defaultIndex = new Map(entries.map((entry, index) => [entry.id, index])); + const orderedIds = 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 | undefined +): boolean { + return hiddenItems?.[item.id] ?? item.defaultHidden ?? false; +} diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx index a7c68f059f5..781b77c65f3 100644 --- a/apps/webapp/app/components/primitives/PageHeader.tsx +++ b/apps/webapp/app/components/primitives/PageHeader.tsx @@ -1,5 +1,5 @@ import { Link, useNavigation } from "@remix-run/react"; -import { type ReactNode } from "react"; +import { createContext, useContext, useEffect, useState, type ReactNode } from "react"; import { QuestionMarkIcon } from "~/assets/icons/QuestionMarkIcon"; import { OrgBanner } from "../billing/OrgBanner"; import { BreadcrumbIcon } from "./BreadcrumbIcon"; @@ -7,21 +7,34 @@ 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; className?: string; }; +/** + * PageTitle reports its title (when it's a plain string) up to the NavBar here, so the favorite + * button can name the page in its tooltip and default label without every page wiring it through. + */ +const PageTitleRegistrationContext = createContext<((title: string | undefined) => void) | null>( + null +); + export function NavBar({ children }: WithChildren) { const navigation = useNavigation(); const isLoading = navigation.state === "loading" || navigation.state === "submitting"; + const [pageTitle, setPageTitle] = useState(undefined); return (
-
{children}
+ +
{children}
+
+
@@ -46,6 +59,15 @@ type PageTitleProps = { }; export function PageTitle({ title, backButton, accessory }: PageTitleProps) { + const setPageTitle = useContext(PageTitleRegistrationContext); + const titleText = typeof title === "string" ? title : undefined; + + useEffect(() => { + if (!setPageTitle) return; + setPageTitle(titleText); + return () => setPageTitle(undefined); + }, [setPageTitle, titleText]); + return (
{backButton && ( diff --git a/apps/webapp/app/routes/resources.preferences.favorites.tsx b/apps/webapp/app/routes/resources.preferences.favorites.tsx new file mode 100644 index 00000000000..5005ca096fc --- /dev/null +++ b/apps/webapp/app/routes/resources.preferences.favorites.tsx @@ -0,0 +1,72 @@ +import { json, type ActionFunctionArgs } from "@remix-run/node"; +import { z } from "zod"; +import { + addFavorite, + removeFavorite, + renameFavorite, +} from "~/services/dashboardPreferences.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 }); + } + + 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; + } + } + + return json({ success: true }); +} diff --git a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx index 4e43269d0ea..59c7bf7479d 100644 --- a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx +++ b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx @@ -4,7 +4,11 @@ import { SideMenuSectionIdSchema, type SideMenuSectionId, } from "~/components/navigation/sideMenuTypes"; -import { updateItemOrder, updateSideMenuPreferences } from "~/services/dashboardPreferences.server"; +import { + updateItemOrder, + updateSideMenuCustomization, + updateSideMenuPreferences, +} from "~/services/dashboardPreferences.server"; import { requireUser } from "~/services/session.server"; // Transforms form data string "true"/"false" to boolean, or undefined if not present @@ -22,6 +26,19 @@ 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(), }); export async function action({ request }: ActionFunctionArgs) { @@ -35,6 +52,29 @@ 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 } = customizationResult.data; + await updateSideMenuCustomization({ + user, + sectionOrder, + hiddenItems, + sectionItemOrder, + favorites, + }); + return json({ success: true }); + } + // Handle item order update if (result.data.organizationId && result.data.listId && result.data.itemOrder) { let parsed: unknown; diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index a8b9149eff5..67a3dec96a1 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -3,6 +3,19 @@ import { 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; + 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; @@ -185,6 +206,160 @@ export async function updateSideMenuPreferences({ }); } +/** The most favorites a user can save; a sanity cap, not a product limit. */ +const MAX_FAVORITES = 50; + +async function saveSideMenu(user: UserFromSession, sideMenu: SideMenuPreferences) { + const updatedPreferences: DashboardPreferences = { + ...user.dashboardPreferences, + sideMenu, + }; + + return prisma.user.update({ + where: { + id: user.id, + }, + data: { + dashboardPreferences: updatedPreferences, + }, + }); +} + +export async function addFavorite({ + user, + favorite, +}: { + user: UserFromSession; + favorite: FavoritePage; +}) { + if (user.isImpersonating) { + return; + } + + const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {}); + const favorites = currentSideMenu.favorites ?? []; + + // 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; + } + + if (favorites.length >= MAX_FAVORITES) { + return; + } + + return saveSideMenu(user, { ...currentSideMenu, favorites: [...favorites, favorite] }); +} + +export async function removeFavorite({ user, id }: { user: UserFromSession; id: string }) { + if (user.isImpersonating) { + return; + } + + const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {}); + const favorites = currentSideMenu.favorites ?? []; + const remaining = favorites.filter((f) => f.id !== id); + + if (remaining.length === favorites.length) { + return; + } + + return saveSideMenu(user, { + ...currentSideMenu, + favorites: remaining.length > 0 ? remaining : undefined, + }); +} + +export async function renameFavorite({ + user, + id, + label, +}: { + user: UserFromSession; + id: string; + label: string; +}) { + if (user.isImpersonating) { + return; + } + + const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.sideMenu ?? {}); + const favorites = currentSideMenu.favorites ?? []; + + const favorite = favorites.find((f) => f.id === id); + if (!favorite || favorite.label === label) { + return; + } + + return saveSideMenu(user, { + ...currentSideMenu, + favorites: favorites.map((f) => (f.id === id ? { ...f, label } : f)), + }); +} + +export async function updateSideMenuCustomization({ + user, + sectionOrder, + hiddenItems, + sectionItemOrder, + favorites, +}: { + user: UserFromSession; + /** undefined = leave unchanged, null = reset to default */ + sectionOrder?: string[] | null; + /** undefined = leave unchanged, null = reset to default */ + hiddenItems?: Record | null; + /** undefined = leave unchanged, null = reset to default */ + sectionItemOrder?: Record | null; + /** Full favorites arrangement: new order + labels. undefined = leave unchanged. */ + favorites?: Array<{ id: string; label: string }>; +}) { + if (user.isImpersonating) { + return; + } + + const currentSideMenu = SideMenuPreferences.parse(user.dashboardPreferences.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) { + const current = currentSideMenu.favorites ?? []; + 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 saveSideMenu(user, SideMenuPreferences.parse(next)); +} + /** Get the stored item order for a specific list within an organization */ export function getItemOrder( sideMenu: SideMenuPreferences | undefined, From d4eb604cd557710e0f386f5a5be81fc64d1a224b Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sat, 25 Jul 2026 11:53:27 +0100 Subject: [PATCH 02/18] fix(webapp): favorites and sidebar customization polish Move the favorite star next to the page title, brighten it on hover, and use a favorite's custom name in its tooltip. Favorite links now carry a marker param so only the favorite highlights as active (with its page's icon color), never its identical main menu item. Fix removing a favorite from its hover menu not persisting: the mutation fetcher now lives in the side menu, since the item unmounts optimistically and an unmounting owner's request gets aborted. Side menu labels fade out at the right edge when they overflow instead of hard-clipping. Section headers keep their hover state while hovering the header ellipsis, the More menu gains a Customize sidebar entry, and popover menu items use the larger side menu size. In the Customize sidebar modal: favorite name fields match the row text size, blank names show an error and block Confirm, Reset keeps custom names while restoring layout, the footer divider spans the full modal width, and the scrollbar sits at the modal edge. Built-in metric dashboards now save with their own icons and names when favorited. --- .../navigation/CustomizeSidebarDialog.tsx | 41 ++++-- .../navigation/FavoritePageButton.tsx | 10 +- .../navigation/FavoritesSection.tsx | 44 +++--- .../app/components/navigation/SideMenu.tsx | 88 +++++++++--- .../components/navigation/SideMenuItem.tsx | 74 +++++++++- .../components/navigation/SideMenuSection.tsx | 7 +- .../components/navigation/favoritePages.tsx | 132 ++++++++++++------ .../components/navigation/sideMenuTypes.ts | 5 + .../app/components/primitives/PageHeader.tsx | 24 +--- 9 files changed, 302 insertions(+), 123 deletions(-) diff --git a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx index d96b8a84fa9..ba665346f17 100644 --- a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx +++ b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx @@ -6,6 +6,7 @@ import ReactGridLayout, { type Layout, useContainerWidth } from "react-grid-layo 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"; @@ -141,7 +142,15 @@ export function CustomizeSidebarDialog({ setState((current) => ({ ...current, labels: { ...current.labels, [itemId]: label } })); }; - const reset = () => setState(buildState(sections, undefined)); + // Reset restores the default layout (positions + visibility) but never touches favorite names + const reset = () => + setState((current) => ({ ...buildState(sections, undefined), labels: current.labels })); + + const hasBlankLabels = sections.some((section) => + section.items.some( + (item) => item.isFavorite && (state.labels[item.id] ?? item.name).trim().length === 0 + ) + ); const confirm = () => { const defaults = buildState(sections, undefined); @@ -193,7 +202,9 @@ export function CustomizeSidebarDialog({ return ( Customize sidebar -
+ {/* Bleeds through the container's right padding (-mr-4/pr-4) so the scrollbar sits at the + modal edge while the content keeps its visual inset */} +
{orderedSections.map((section, index) => (
@@ -227,7 +238,8 @@ export function CustomizeSidebarDialog({
))}
- + {/* Negative margins stretch the top divider across the modal's full width */} +
@@ -236,7 +248,7 @@ export function CustomizeSidebarDialog({ Reset
-
@@ -367,14 +379,19 @@ function ModalItemRow({ > {item.isFavorite ? ( - onLabelChange(e.target.value)} - variant="small" - maxLength={64} - containerClassName="max-w-60" - aria-label={`Rename ${item.name}`} - /> + <> + onLabelChange(e.target.value)} + variant="medium" + maxLength={64} + containerClassName="max-w-60" + aria-label={`Rename ${item.name}`} + /> + {(label ?? item.name).trim().length === 0 && ( + Name can't be blank + )} + ) : ( {item.name} )} diff --git a/apps/webapp/app/components/navigation/FavoritePageButton.tsx b/apps/webapp/app/components/navigation/FavoritePageButton.tsx index 34a4f2c84b3..6288505efc1 100644 --- a/apps/webapp/app/components/navigation/FavoritePageButton.tsx +++ b/apps/webapp/app/components/navigation/FavoritePageButton.tsx @@ -12,6 +12,7 @@ import { buildFavoriteLabel, FAVORITES_ACTION_PATH, resolvePageMeta, + stripFavoriteSearchParam, useFavorites, } from "./favoritePages"; @@ -27,10 +28,13 @@ export function FavoritePageButton({ pageTitle }: { pageTitle?: string }) { const fetcher = useFetcher(); const { areShortcutsEnabled } = useShortcuts(); - const url = location.pathname + location.search; + // The favorite marker param is presentation-only, so it never counts toward URL identity + const url = location.pathname + stripFavoriteSearchParam(location.search); const existing = favorites.find((favorite) => favorite.url === url); const isFavorited = existing !== undefined; - const pageName = pageTitle?.trim() || resolvePageMeta(location.pathname).name; + // A renamed favorite keeps its custom name in the tooltip + const pageName = + existing?.label ?? (pageTitle?.trim() || resolvePageMeta(location.pathname).name); const toggle = () => { if (existing) { @@ -119,7 +123,7 @@ export function FavoritePageButton({ pageTitle }: { pageTitle?: string }) { isFavorited ? ( ) : ( - + ) } /> diff --git a/apps/webapp/app/components/navigation/FavoritesSection.tsx b/apps/webapp/app/components/navigation/FavoritesSection.tsx index 79e29811efb..2f90a115b7d 100644 --- a/apps/webapp/app/components/navigation/FavoritesSection.tsx +++ b/apps/webapp/app/components/navigation/FavoritesSection.tsx @@ -1,5 +1,5 @@ import { EllipsisHorizontalIcon, PencilSquareIcon, TrashIcon } from "@heroicons/react/20/solid"; -import { useFetcher, useLocation, useNavigation } from "@remix-run/react"; +import { useLocation, useNavigation } from "@remix-run/react"; import { useEffect, useRef, useState } from "react"; import { type FavoritePage } from "~/services/dashboardPreferences.server"; import { cn } from "~/utils/cn"; @@ -10,23 +10,35 @@ import { PopoverCustomTrigger, PopoverMenuItem, } from "../primitives/Popover"; -import { favoritePageIcon, FAVORITES_ACTION_PATH } from "./favoritePages"; +import { + favoriteLinkTo, + favoritePageActiveColor, + favoritePageIcon, + 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 fetcher = useFetcher(); const [isEditing, setIsEditing] = useState(false); const [isMenuOpen, setMenuOpen] = useState(false); @@ -35,24 +47,14 @@ export function FavoriteMenuItem({ }, [navigation.location?.pathname]); const icon = favoritePageIcon(favorite.icon); - const isActive = location.pathname + location.search === favorite.url; + 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; - fetcher.submit( - { intent: "rename", id: favorite.id, label }, - { method: "POST", action: FAVORITES_ACTION_PATH } - ); - }; - - const remove = () => { - fetcher.submit( - { intent: "remove", id: favorite.id }, - { method: "POST", action: FAVORITES_ACTION_PATH } - ); + onRename(favorite.id, label); }; if (isEditing && !isCollapsed) { @@ -70,9 +72,9 @@ export function FavoriteMenuItem({ { setMenuOpen(false); setIsEditing(true); @@ -109,10 +112,11 @@ export function FavoriteMenuItem({ icon={TrashIcon} title="Remove" danger - leadingIconClassName="size-4" + leadingIconClassName="h-5 w-5" + className={SIDE_MENU_POPOVER_ITEM_LABEL} onClick={() => { setMenuOpen(false); - remove(); + onRemove(favorite.id); }} />
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index a03644c7165..5d74813cc85 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -156,14 +156,25 @@ import { import { CreateDashboardButton } from "./DashboardDialogs"; import { DashboardList } from "./DashboardList"; import { EnvironmentSelector } from "./EnvironmentSelector"; -import { favoritePageIcon, useFavorites } from "./favoritePages"; +import { + FAVORITES_ACTION_PATH, + favoriteLinkTo, + favoritePageIcon, + useFavorites, +} from "./favoritePages"; import { FavoriteMenuItem } from "./FavoritesSection"; import { HelpAndFeedback } from "./HelpAndFeedbackPopover"; import { NotificationPanel } from "./NotificationPanel"; import { SideMenuHeader } from "./SideMenuHeader"; -import { SideMenuItem } from "./SideMenuItem"; +import { SideMenuItem, SideMenuLabel } from "./SideMenuItem"; import { SideMenuSection } from "./SideMenuSection"; -import { isItemHidden, orderByPreference, type SideMenuSectionId } from "./sideMenuTypes"; +import { + isItemHidden, + orderByPreference, + SIDE_MENU_POPOVER_ITEM_ICON, + SIDE_MENU_POPOVER_ITEM_LABEL, + type SideMenuSectionId, +} from "./sideMenuTypes"; /** Get the collapsed state for a specific side menu section from user preferences */ function getSectionCollapsed( @@ -198,11 +209,6 @@ type SideMenuSectionConfig = { items: SideMenuItemConfig[]; }; -// Size popover items (org/project menus) to match the side-menu items, overriding the smaller -// small-menu-item defaults via tailwind-merge; icon carries the default dimmed color. -const SIDE_MENU_POPOVER_ITEM_ICON = "h-5 w-5 text-text-dimmed"; -const SIDE_MENU_POPOVER_ITEM_LABEL = "text-[0.90625rem] font-medium tracking-[-0.01em]"; - // Impersonation accent (menu border + "Stop impersonating"). Full class strings so Tailwind's // static scanner picks them up. const IMPERSONATION_ACCENT = { @@ -396,6 +402,21 @@ export function SideMenu({ { method: "POST", action: "/resources/preferences/sidemenu" } ); }; + // Same ownership rule: removing a favorite optimistically unmounts its menu item (and, for the + // last favorite, the whole section), which would abort an item-owned fetcher mid-request. + const favoriteActionsFetcher = useFetcher(); + const removeFavorite = (id: string) => { + favoriteActionsFetcher.submit( + { intent: "remove", id }, + { method: "POST", action: FAVORITES_ACTION_PATH } + ); + }; + const renameFavorite = (id: string, label: string) => { + favoriteActionsFetcher.submit( + { intent: "rename", id, label }, + { method: "POST", action: FAVORITES_ACTION_PATH } + ); + }; const persistSideMenuPreferences = useCallback( (data: { @@ -1123,6 +1144,9 @@ export function SideMenu({ initialCollapsed={getSectionCollapsed(sideMenuPrefs, "favorites")} onCollapseToggle={handleSectionToggle("favorites")} headerMenu={sectionHeaderMenu} + onCustomize={() => setCustomizeOpen(true)} + onRemoveFavorite={removeFavorite} + onRenameFavorite={renameFavorite} /> ); } @@ -1140,6 +1164,7 @@ export function SideMenu({ initialCollapsed={getSectionCollapsed(sideMenuPrefs, section.id)} onCollapseToggle={handleSectionToggle(section.id)} headerMenu={sectionHeaderMenu} + onCustomize={() => setCustomizeOpen(true)} /> ); })} @@ -1218,6 +1243,7 @@ function CustomizableSideMenuSection({ initialCollapsed, onCollapseToggle, headerMenu, + onCustomize, }: { section: SideMenuSectionConfig; itemOrder: string[] | undefined; @@ -1226,6 +1252,7 @@ function CustomizableSideMenuSection({ initialCollapsed: boolean; onCollapseToggle: (collapsed: boolean) => void; headerMenu: ReactNode; + onCustomize: () => void; }) { const orderedItems = orderByPreference(section.items, itemOrder); const visibleItems = orderedItems.filter((item) => !isItemHidden(item, hiddenItems)); @@ -1266,6 +1293,7 @@ function CustomizableSideMenuSection({ to: item.to, }))} isCollapsed={isCollapsed} + onCustomize={onCustomize} /> )} @@ -1280,6 +1308,9 @@ function FavoritesSideMenuSection({ initialCollapsed, onCollapseToggle, headerMenu, + onCustomize, + onRemoveFavorite, + onRenameFavorite, }: { favorites: FavoritePage[]; hiddenItems: Record | undefined; @@ -1287,6 +1318,9 @@ function FavoritesSideMenuSection({ initialCollapsed: boolean; onCollapseToggle: (collapsed: boolean) => void; headerMenu: ReactNode; + onCustomize: () => void; + onRemoveFavorite: (id: string) => void; + onRenameFavorite: (id: string, label: string) => void; }) { const visible = favorites.filter((favorite) => !(hiddenItems?.[favorite.id] ?? false)); const hidden = favorites.filter((favorite) => hiddenItems?.[favorite.id] ?? false); @@ -1301,7 +1335,13 @@ function FavoritesSideMenuSection({ headerMenu={headerMenu} > {visible.map((favorite) => ( - + ))} {hidden.length > 0 && ( )} @@ -1325,9 +1366,11 @@ function FavoritesSideMenuSection({ function SideMenuMoreItem({ items, isCollapsed, + onCustomize, }: { items: Array<{ id: string; name: string; icon: RenderIcon; to: string }>; isCollapsed: boolean; + onCustomize: () => void; }) { const [isOpen, setOpen] = useState(false); const navigation = useNavigation(); @@ -1342,12 +1385,12 @@ function SideMenuMoreItem({ button={ - More - + } content="More" @@ -1358,8 +1401,8 @@ function SideMenuMoreItem({ tabbable disableHoverableContent /> - -
+ +
{items.map((item) => ( ))}
+
+ { + setOpen(false); + onCustomize(); + }} + /> +
); @@ -1392,7 +1447,8 @@ function SectionHeaderMenu({ onCustomize }: { onCustomize: () => void }) { { setOpen(false); onCustomize(); diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index 1f763f513e7..6330538f20e 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -3,13 +3,70 @@ import { type ButtonHTMLAttributes, forwardRef, type ReactNode, + useEffect, + useRef, + useState, } from "react"; -import { Link } from "@remix-run/react"; +import { Link, useLocation } from "@remix-run/react"; import { motion } from "framer-motion"; import { usePathName } from "~/hooks/usePathName"; import { cn } from "~/utils/cn"; import { type RenderIcon, Icon } from "../primitives/Icon"; import { SimpleTooltip } from "../primitives/Tooltip"; +import { FAVORITE_SEARCH_PARAM } 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(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 ( + + {children} + + ); +} export function SideMenuItem({ icon, @@ -51,7 +108,10 @@ export function SideMenuItem({ "data-action"?: string; }) { const pathName = usePathName(); - const isActive = isActiveOverride ?? pathName === to; + const { search } = useLocation(); + // A favorite marker in the URL means a favorite owns the active state (see favoriteLinkTo) + const hasFavoriteMarker = new URLSearchParams(search).has(FAVORITE_SEARCH_PARAM); + const isActive = isActiveOverride ?? (pathName === to && !hasFavoriteMarker); const isIndented = indented && !isCollapsed; @@ -95,14 +155,14 @@ export function SideMenuItem({ className="flex w-full min-w-0 items-center justify-between" style={{ opacity: "var(--sm-label-opacity, 1)" }} > - {name} - + {badge && !isCollapsed && (
{badge}
)} @@ -192,9 +252,9 @@ export const SideMenuItemButton = forwardRef< icon={icon} className="size-5 shrink-0 text-text-dimmed group-hover/menuitem:text-text-bright" /> - + {name} - + {trailing && {trailing}} ); diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx index 8cb3c3ef0e0..102013a6e8c 100644 --- a/apps/webapp/app/components/navigation/SideMenuSection.tsx +++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx @@ -59,8 +59,9 @@ export function SideMenuSection({
); } diff --git a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx index 8aa6427afbf..153004813a8 100644 --- a/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx +++ b/apps/webapp/app/routes/_app.orgs.$organizationSlug.projects.$projectParam.env.$envParam.dashboards.custom.$dashboardId/route.tsx @@ -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 }, diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index 7745ed5d719..2f13930b9c2 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -275,6 +275,41 @@ export async function removeFavorite({ user, id }: { user: UserFromSession; id: }); } +/** + * 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, From c9eb2265ed13c8b161f409f3d33bb2482fcb5e2b Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sat, 25 Jul 2026 20:03:39 +0100 Subject: [PATCH 06/18] 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. --- .../navigation/FavoritePageButton.tsx | 7 +- .../app/components/navigation/SideMenu.tsx | 28 ++-- .../components/navigation/favoritePages.tsx | 124 +++++++++++++++--- .../app/components/primitives/PageHeader.tsx | 32 +++-- 4 files changed, 147 insertions(+), 44 deletions(-) diff --git a/apps/webapp/app/components/navigation/FavoritePageButton.tsx b/apps/webapp/app/components/navigation/FavoritePageButton.tsx index a96f5d1d336..14a485294a9 100644 --- a/apps/webapp/app/components/navigation/FavoritePageButton.tsx +++ b/apps/webapp/app/components/navigation/FavoritePageButton.tsx @@ -60,8 +60,9 @@ export function FavoritePageButton({ const existing = favorites.find((favorite) => 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, e.g. "Run: 05hrqq9n") - const pageName = existing?.label ?? buildFavoriteLabel(location.pathname, pageTitle); + // (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) { @@ -75,7 +76,7 @@ export function FavoritePageButton({ intent: "add", id: crypto.randomUUID(), url, - label: buildFavoriteLabel(location.pathname, pageTitle), + label: buildFavoriteLabel(location.pathname, location.search, pageTitle), icon: resolvePageMeta(location.pathname).icon, }, { method: "POST", action: FAVORITES_ACTION_PATH } diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index d28f908951d..2afbf467513 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -1414,7 +1414,8 @@ function SideMenuMoreItem({ /> ))}
-
+ {/* flex-col blockifies the inline-block menu item, avoiding stray line-box space below */} +
void }) { > - - { - setOpen(false); - onCustomize(); - }} - /> + + {/* flex-col blockifies the inline-block menu item, avoiding stray line-box space below */} +
+ { + setOpen(false); + onCustomize(); + }} + /> +
); diff --git a/apps/webapp/app/components/navigation/favoritePages.tsx b/apps/webapp/app/components/navigation/favoritePages.tsx index 87827c254e1..575f09b1c6f 100644 --- a/apps/webapp/app/components/navigation/favoritePages.tsx +++ b/apps/webapp/app/components/navigation/favoritePages.tsx @@ -121,24 +121,28 @@ export function stripFavoriteSearchParam(search: string): string { return result.length > 0 ? `?${result}` : ""; } -/** A favorite is active when its marker param is in the URL and the pathname still matches. */ +/** + * A favorite is active only while the URL is exactly the view it saved: its marker param is + * present AND the rest of the 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). + */ export function isFavoriteActive( favorite: FavoritePage, pathname: string, search: string ): boolean { - const favoritePath = favorite.url.split("?")[0]; return ( - pathname === favoritePath && - new URLSearchParams(search).get(FAVORITE_SEARCH_PARAM) === favorite.id + new URLSearchParams(search).get(FAVORITE_SEARCH_PARAM) === favorite.id && + favorite.url === pathname + stripFavoriteSearchParam(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. A marker arriving via someone else's shared - * link (or a removed favorite's stale link) resolves to undefined, so the page loads with - * regular menu highlighting instead of suppressing 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(); @@ -146,7 +150,9 @@ export function useActiveFavoriteId(): string | undefined { const marker = new URLSearchParams(location.search).get(FAVORITE_SEARCH_PARAM); if (!marker) return undefined; - return favorites.some((favorite) => favorite.id === marker) ? marker : undefined; + const favorite = favorites.find((f) => f.id === marker); + if (!favorite) return undefined; + return isFavoriteActive(favorite, location.pathname, location.search) ? marker : undefined; } type PageMeta = { @@ -253,6 +259,10 @@ export function resolvePageMeta(pathname: string): PageMeta { 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 @@ -267,31 +277,115 @@ function detailIdFromPath(pathname: string): string | undefined { return undefined; } +/** Task type filter on the Tasks page (?types=…) becomes the whole favorite name. */ +const TASK_TYPE_LABELS: Record = { + 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, "cursor", "direction", "page", "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(); + + 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. 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"). Users can rename. + * 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, pageTitle: string | undefined): string { +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 a detail page; prefer the short id + // 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) { + // 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); - return detailId ? `${prefix}: ${detailId}` : meta.name; + 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 label.length > MAX_LABEL_LENGTH ? `${label.slice(0, MAX_LABEL_LENGTH - 1)}…` : label; + return truncateLabel(label); } /** diff --git a/apps/webapp/app/components/primitives/PageHeader.tsx b/apps/webapp/app/components/primitives/PageHeader.tsx index e3fe4f18064..691b9323bad 100644 --- a/apps/webapp/app/components/primitives/PageHeader.tsx +++ b/apps/webapp/app/components/primitives/PageHeader.tsx @@ -50,7 +50,7 @@ export function PageTitle({ title, backButton, accessory }: PageTitleProps) { const titleText = typeof title === "string" ? title : undefined; return ( -
+
{backButton && (
)} {title} - {accessory !== undefined && - (typeof accessory === "string" ? ( - } - content={accessory} - className="max-w-xs" - disableHoverableContent - /> - ) : ( - accessory - ))} - {/* -ml-1 cancels the row gap: the star's own inner padding then provides the visual gap, - matching the title-to-accessory spacing while the button box stays flush for hover */} + {accessory !== undefined && ( + // ml-px optically evens the accessory against the title's tight text edge + + {typeof accessory === "string" ? ( + } + content={accessory} + className="max-w-xs" + disableHoverableContent + /> + ) : ( + accessory + )} + + )} + {/* -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 */}
); From c37b871327e2ea7b43bafcaeea1d680c6d7e0b41 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sat, 25 Jul 2026 21:00:31 +0100 Subject: [PATCH 07/18] feat(webapp): remove favorites from the customize modal, custom action icons Favorite rows in the customize sidebar modal gain a remove button next to the visibility toggle; removals stage in the modal and apply on Confirm (Cancel discards them). New custom icons replace the previous ones for Remove, Rename, and Customize sidebar. Slack and Vercel favorites render their brand logos at the same size as the organization menu, and favoriting a custom dashboard uses the dashboard chart icon. --- apps/webapp/app/assets/icons/CrossIcon.tsx | 14 ++++ apps/webapp/app/assets/icons/RenameIcon.tsx | 27 ++++++++ .../app/assets/icons/SidebarCustomizeIcon.tsx | 26 +++++++ .../navigation/CustomizeSidebarDialog.tsx | 67 ++++++++++++++++--- .../navigation/FavoritesSection.tsx | 15 +++-- .../app/components/navigation/SideMenu.tsx | 14 ++-- .../components/navigation/favoritePages.tsx | 29 ++++++-- .../routes/resources.preferences.sidemenu.tsx | 5 +- .../services/dashboardPreferences.server.ts | 10 ++- 9 files changed, 180 insertions(+), 27 deletions(-) create mode 100644 apps/webapp/app/assets/icons/CrossIcon.tsx create mode 100644 apps/webapp/app/assets/icons/RenameIcon.tsx create mode 100644 apps/webapp/app/assets/icons/SidebarCustomizeIcon.tsx diff --git a/apps/webapp/app/assets/icons/CrossIcon.tsx b/apps/webapp/app/assets/icons/CrossIcon.tsx new file mode 100644 index 00000000000..8a92fc7c711 --- /dev/null +++ b/apps/webapp/app/assets/icons/CrossIcon.tsx @@ -0,0 +1,14 @@ +export function CrossIcon({ className }: { className?: string }) { + return ( + + + + ); +} diff --git a/apps/webapp/app/assets/icons/RenameIcon.tsx b/apps/webapp/app/assets/icons/RenameIcon.tsx new file mode 100644 index 00000000000..6514e0b09cd --- /dev/null +++ b/apps/webapp/app/assets/icons/RenameIcon.tsx @@ -0,0 +1,27 @@ +export function RenameIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/assets/icons/SidebarCustomizeIcon.tsx b/apps/webapp/app/assets/icons/SidebarCustomizeIcon.tsx new file mode 100644 index 00000000000..0960e4a2011 --- /dev/null +++ b/apps/webapp/app/assets/icons/SidebarCustomizeIcon.tsx @@ -0,0 +1,26 @@ +export function SidebarCustomizeIcon({ className }: { className?: string }) { + return ( + + + + + ); +} diff --git a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx index 0631dada8f9..0325d7838c9 100644 --- a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx +++ b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx @@ -3,6 +3,7 @@ import { ArrowDownIcon, ArrowUpIcon, EyeIcon, EyeSlashIcon } from "@heroicons/re 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 { cn } from "~/utils/cn"; import { Button } from "../primitives/Buttons"; import { DialogContent, DialogFooter, DialogHeader } from "../primitives/Dialog"; @@ -16,6 +17,7 @@ export type CustomizeSidebarItem = { id: string; name: string; icon: RenderIcon; + iconClassName?: string; defaultHidden?: boolean; /** Favorites get an inline-editable name in the modal. */ isFavorite?: boolean; @@ -40,6 +42,7 @@ export type SidebarCustomizationPayload = { hiddenItems: Record | null; sectionItemOrder: Record | null; favorites?: Array<{ id: string; label: string }>; + removedFavoriteIds?: string[]; }; type DialogState = { @@ -50,6 +53,8 @@ type DialogState = { hidden: Record; /** favorite id -> label being edited */ labels: Record; + /** favorite ids staged for removal; applied on Confirm */ + removed: string[]; }; const FAVORITES_SECTION_ID = "favorites"; @@ -84,7 +89,13 @@ function buildState( } } - return { sectionOrder: orderedSections.map((section) => section.id), itemOrders, hidden, labels }; + return { + sectionOrder: orderedSections.map((section) => section.id), + itemOrders, + hidden, + labels, + removed: [], + }; } function arraysEqual(a: string[], b: string[]) { @@ -142,13 +153,25 @@ export function CustomizeSidebarDialog({ 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 })); + setState((current) => ({ + ...buildState(sections, undefined), + labels: current.labels, + removed: current.removed, + })); const hasBlankLabels = sections.some((section) => section.items.some( - (item) => item.isFavorite && (state.labels[item.id] ?? item.name).trim().length === 0 + (item) => + item.isFavorite && + !state.removed.includes(item.id) && + (state.labels[item.id] ?? item.name).trim().length === 0 ) ); @@ -158,6 +181,7 @@ export function CustomizeSidebarDialog({ const hiddenOverrides: Record = {}; 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; @@ -175,12 +199,17 @@ export function CustomizeSidebarDialog({ } const favoritesSection = sections.find((section) => section.id === FAVORITES_SECTION_ID); - const favoriteOrder = state.itemOrders[FAVORITES_SECTION_ID] ?? []; + const favoriteOrder = (state.itemOrders[FAVORITES_SECTION_ID] ?? []).filter( + (id) => !state.removed.includes(id) + ); const favoritesChanged = favoritesSection !== undefined && - (!arraysEqual(favoriteOrder, defaults.itemOrders[FAVORITES_SECTION_ID] ?? []) || + (state.removed.length > 0 || + !arraysEqual(favoriteOrder, defaults.itemOrders[FAVORITES_SECTION_ID] ?? []) || favoritesSection.items.some( - (item) => (state.labels[item.id] ?? item.name).trim() !== item.name + (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 @@ -193,6 +222,7 @@ export function CustomizeSidebarDialog({ favorites: favoritesChanged ? favoriteOrder.map((id) => ({ id, label: state.labels[id] ?? "" })) : undefined, + removedFavoriteIds: state.removed.length > 0 ? state.removed : undefined, }; onConfirm(payload); @@ -229,12 +259,15 @@ export function CustomizeSidebarDialog({
item.id)} + 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} />
))} @@ -289,6 +322,7 @@ function SectionItemList({ onReorder, onToggleHidden, onLabelChange, + onRemove, }: { section: CustomizeSidebarSection; order: string[]; @@ -297,6 +331,7 @@ function SectionItemList({ onReorder: (itemIds: string[]) => void; onToggleHidden: (itemId: string) => void; onLabelChange: (itemId: string, label: string) => void; + onRemove: (itemId: string) => void; }) { const { width, containerRef } = useContainerWidth({ initialWidth: 416 }); @@ -321,6 +356,7 @@ function SectionItemList({ draggable={options.draggable} onToggleHidden={() => onToggleHidden(item.id)} onLabelChange={(label) => onLabelChange(item.id, label)} + onRemove={() => onRemove(item.id)} /> ); @@ -359,6 +395,7 @@ function ModalItemRow({ draggable, onToggleHidden, onLabelChange, + onRemove, }: { item: CustomizeSidebarItem; isHidden: boolean; @@ -366,6 +403,7 @@ function ModalItemRow({ draggable: boolean; onToggleHidden: () => void; onLabelChange: (label: string) => void; + onRemove: () => void; }) { return (
- + {item.isFavorite ? ( <>
+ {item.isFavorite && ( + + )} {draggable ? (
From e5e0387e79c3e2ba06a02319bb81d026a05e1942 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sat, 25 Jul 2026 21:14:10 +0100 Subject: [PATCH 10/18] fix(webapp): resting gap above the first customize modal section --- .../app/components/navigation/CustomizeSidebarDialog.tsx | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx index af913df2830..c3e6f092a22 100644 --- a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx +++ b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx @@ -235,9 +235,10 @@ export function CustomizeSidebarDialog({ Customize sidebar {/* 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 content scrolls flush - against the header divider and the footer's top border */} -
+ 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 is INSIDE + the scrollport: a resting gap above the first title that content scrolls through. */} +
{orderedSections.map((section, index) => (
From 655321d99eead413b323b228fbba3d321a3fd0bb Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sat, 25 Jul 2026 21:15:20 +0100 Subject: [PATCH 11/18] fix(webapp): matching resting gap below the customize modal list --- .../app/components/navigation/CustomizeSidebarDialog.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx index c3e6f092a22..087b1cdd260 100644 --- a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx +++ b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx @@ -236,9 +236,9 @@ export function CustomizeSidebarDialog({ Customize sidebar {/* 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 is INSIDE - the scrollport: a resting gap above the first title that content scrolls through. */} -
+ 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. */} +
{orderedSections.map((section, index) => (
From 78d301b3e2ebde4bb03e55ed2d9508d1b9e1a5cf Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sat, 25 Jul 2026 21:28:18 +0100 Subject: [PATCH 12/18] fix(webapp): drop the Favorites section from the modal with its last favorite --- .../navigation/CustomizeSidebarDialog.tsx | 35 +++++++++++++------ 1 file changed, 25 insertions(+), 10 deletions(-) diff --git a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx index 087b1cdd260..65609d0fb42 100644 --- a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx +++ b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx @@ -123,16 +123,31 @@ export function CustomizeSidebarDialog({ }) { const [state, setState] = useState(() => buildState(sections, prefs)); - const orderedSections = state.sectionOrder - .map((id) => sections.find((section) => section.id === id)) - .filter((section): section is CustomizeSidebarSection => section !== undefined); - - const moveSection = (index: number, direction: -1 | 1) => { + // 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 target = index + direction; - if (target < 0 || target >= next.length) return current; - [next[index], next[target]] = [next[target], next[index]]; + const a = next.indexOf(sectionId); + const b = next.indexOf(neighborId); + [next[a], next[b]] = [next[b], next[a]]; return { ...current, sectionOrder: next }; }); }; @@ -247,14 +262,14 @@ export function CustomizeSidebarDialog({ moveSection(index, -1)} + onClick={() => moveSection(section.id, -1)} > moveSection(index, 1)} + onClick={() => moveSection(section.id, 1)} > From d6d18893ed55c20e5ea2504ed7221e89777f487e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sat, 25 Jul 2026 21:45:36 +0100 Subject: [PATCH 13/18] fix(webapp): address review feedback on favorites and sidebar customization Skip the locked preference transaction on the hot current-environment save when the session snapshot already matches, so plain page loads do no extra database work. Give favorite rename and remove their own fetchers so quick successive mutations can't cancel each other. Close favorite and More popovers when navigation changes only the search string, keep the section options trigger visible while it holds keyboard focus, and dedupe saved order ids defensively. --- .../app/components/navigation/FavoritesSection.tsx | 3 ++- apps/webapp/app/components/navigation/SideMenu.tsx | 12 ++++++++---- .../app/components/navigation/SideMenuSection.tsx | 3 ++- .../app/components/navigation/sideMenuTypes.ts | 3 ++- .../app/services/dashboardPreferences.server.ts | 10 ++++++++++ 5 files changed, 24 insertions(+), 7 deletions(-) diff --git a/apps/webapp/app/components/navigation/FavoritesSection.tsx b/apps/webapp/app/components/navigation/FavoritesSection.tsx index 26853be1e2b..a540060913f 100644 --- a/apps/webapp/app/components/navigation/FavoritesSection.tsx +++ b/apps/webapp/app/components/navigation/FavoritesSection.tsx @@ -45,9 +45,10 @@ export function FavoriteMenuItem({ 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?.pathname, navigation.location?.search]); const icon = favoritePageIcon(favorite.icon); const isActive = isFavoriteActive(favorite, location.pathname, location.search); diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 866340c9007..60586528bbc 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -406,15 +406,18 @@ export function SideMenu({ }; // Same ownership rule: removing a favorite optimistically unmounts its menu item (and, for the // last favorite, the whole section), which would abort an item-owned fetcher mid-request. - const favoriteActionsFetcher = useFetcher(); + // Separate fetchers per mutation: fetchers are single-flight, so a shared one would cancel an + // in-flight rename when a remove follows quickly (or vice versa). + const removeFavoriteFetcher = useFetcher(); + const renameFavoriteFetcher = useFetcher(); const removeFavorite = (id: string) => { - favoriteActionsFetcher.submit( + removeFavoriteFetcher.submit( { intent: "remove", id }, { method: "POST", action: FAVORITES_ACTION_PATH } ); }; const renameFavorite = (id: string, label: string) => { - favoriteActionsFetcher.submit( + renameFavoriteFetcher.submit( { intent: "rename", id, label }, { method: "POST", action: FAVORITES_ACTION_PATH } ); @@ -1379,9 +1382,10 @@ function SideMenuMoreItem({ const [isOpen, setOpen] = useState(false); const navigation = useNavigation(); + // Watch search too: navigating to a favorite can change only the search on the same pathname useEffect(() => { setOpen(false); - }, [navigation.location?.pathname]); + }, [navigation.location?.pathname, navigation.location?.search]); return ( diff --git a/apps/webapp/app/components/navigation/SideMenuSection.tsx b/apps/webapp/app/components/navigation/SideMenuSection.tsx index 17faedc8089..4f0d37e2716 100644 --- a/apps/webapp/app/components/navigation/SideMenuSection.tsx +++ b/apps/webapp/app/components/navigation/SideMenuSection.tsx @@ -90,7 +90,8 @@ export function SideMenuSection({ 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 */} +
{headerMenu}
diff --git a/apps/webapp/app/components/navigation/sideMenuTypes.ts b/apps/webapp/app/components/navigation/sideMenuTypes.ts index fdfdc9415e6..508c4121175 100644 --- a/apps/webapp/app/components/navigation/sideMenuTypes.ts +++ b/apps/webapp/app/components/navigation/sideMenuTypes.ts @@ -41,7 +41,8 @@ export function orderByPreference( if (!savedOrder || savedOrder.length === 0) return entries; const defaultIndex = new Map(entries.map((entry, index) => [entry.id, index])); - const orderedIds = savedOrder.filter((id) => defaultIndex.has(id)); + // 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) { diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index 1b80fc75223..d2749c08e4c 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -130,6 +130,16 @@ export async function updateCurrentProjectEnvironmentId({ return; } + // 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 + ) { + return; + } + return mutateDashboardPreferences(user.id, (prefs) => { //only update if the existing preferences are different if ( From 9376d9a663387ab07a0fc27b07d63365a560552c Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 26 Jul 2026 09:57:41 +0100 Subject: [PATCH 14/18] fix(webapp): name task and agent favorites after the task Favoriting a task page produced "Page": the resolver keyed the tasks list under the environment index, so the task detail path fell through to the unnamed fallback. Task and agent pages now take the task's name from the URL as the favorite label ("hello-world") and carry their task-type icon (standard, scheduled, or agent), matching how the tasks list shows them. Their page headers render the name as composed markup, so the URL is the only place it can be read from. Also names the playground route, which reported "Page" the same way. --- .../components/navigation/favoritePages.tsx | 41 ++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/apps/webapp/app/components/navigation/favoritePages.tsx b/apps/webapp/app/components/navigation/favoritePages.tsx index 984eb66a09a..58e6e05c7a9 100644 --- a/apps/webapp/app/components/navigation/favoritePages.tsx +++ b/apps/webapp/app/components/navigation/favoritePages.tsx @@ -1,6 +1,9 @@ -import { BeakerIcon, ClockIcon } from "@heroicons/react/24/outline"; +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"; @@ -62,6 +65,10 @@ const FAVORITE_PAGE_ICONS: Record< { 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" }, @@ -176,6 +183,12 @@ type PageMeta = { 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 = { @@ -202,6 +215,8 @@ const ENV_PAGE_META: Record = { 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 = { @@ -242,6 +257,27 @@ export function resolvePageMeta(pathname: string): PageMeta { 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" }; } @@ -384,6 +420,9 @@ export function buildFavoriteLabel( 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"); From fd8f3232c82f66b0acd95fba34fbd2ce5af80d3e Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Sun, 26 Jul 2026 11:47:18 +0100 Subject: [PATCH 15/18] fix(webapp): surface customize sidebar save failures instead of closing silently A confirmed customization could silently vanish: the modal closed the moment Confirm was clicked, so a save that failed or hung (dev server hiccups, transaction timeouts) looked identical to a successful one, and a thrown preferences write escalated to the app error boundary. Confirm now spins until the save response and the refreshed side menu data land, the dialog stays open with an inline error on failure, the preference routes report failures as responses instead of throwing, and the preferences transaction retries retriable Prisma errors. --- .../navigation/CustomizeSidebarDialog.tsx | 31 +++++++--- .../app/components/navigation/SideMenu.tsx | 60 +++++++++++++++++-- .../resources.preferences.favorites.tsx | 34 +++++++---- .../routes/resources.preferences.sidemenu.tsx | 28 ++++++--- .../services/dashboardPreferences.server.ts | 44 ++++++++------ 5 files changed, 145 insertions(+), 52 deletions(-) diff --git a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx index 65609d0fb42..d2c18442e3b 100644 --- a/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx +++ b/apps/webapp/app/components/navigation/CustomizeSidebarDialog.tsx @@ -113,13 +113,21 @@ export function CustomizeSidebarDialog({ sections, prefs, onConfirm, - onClose, + isConfirming, + confirmError, }: { sections: CustomizeSidebarSection[]; prefs: SavedPreferences | undefined; - /** Owned by the parent: closing this dialog unmounts it, so it can't run its own fetcher. */ + /** + * 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; - onClose: () => 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(() => buildState(sections, prefs)); @@ -243,7 +251,6 @@ export function CustomizeSidebarDialog({ }; onConfirm(payload); - onClose(); }; return ( @@ -300,9 +307,19 @@ export function CustomizeSidebarDialog({ Reset
- +
+ {confirmError && !isConfirming && ( + {confirmError} + )} + +
); diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index 60586528bbc..dd5ebcfa4e6 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -4,7 +4,7 @@ import { ExclamationTriangleIcon, } from "@heroicons/react/24/outline"; import { EllipsisHorizontalIcon } from "@heroicons/react/20/solid"; -import { useFetcher, useNavigation, useSubmit } from "@remix-run/react"; +import { useFetcher, useNavigation, useRevalidator, useSubmit } from "@remix-run/react"; import { LayoutGroup, motion } from "framer-motion"; import { type CSSProperties, @@ -395,15 +395,51 @@ export function SideMenu({ const isV3Project = project.engine === "V1"; const favorites = useFavorites(); const [isCustomizeOpen, setCustomizeOpen] = useState(false); - // Lives here (not in the dialog): confirming closes/unmounts the dialog, which would abort a - // fetcher owned by it before the request fires. - const customizationFetcher = useFetcher(); + // Lives here (not in the dialog): the dialog unmounts on close, which would abort a fetcher it + // owned mid-request. + const customizationFetcher = useFetcher<{ success: boolean }>(); + const revalidator = useRevalidator(); + // Confirm lifecycle: the dialog stays open (Confirm spinning) until the save response AND the + // revalidated preferences land, so the side menu is already updated the moment it closes — and + // a failed or hung save shows as such instead of silently reading as success. + const [isCustomizeConfirmPending, setCustomizeConfirmPending] = useState(false); + const [customizeError, setCustomizeError] = useState(); + // The fetcher's data survives across confirms, so only settle once THIS submission has been + // seen in flight — otherwise a reopened dialog could consume the previous confirm's response. + const customizeSubmitSeenRef = useRef(false); const submitSidebarCustomization = (payload: SidebarCustomizationPayload) => { + setCustomizeError(undefined); + setCustomizeConfirmPending(true); + customizeSubmitSeenRef.current = false; customizationFetcher.submit( { customization: JSON.stringify(payload) }, { method: "POST", action: "/resources/preferences/sidemenu" } ); }; + useEffect(() => { + if (!isCustomizeConfirmPending) return; + if (customizationFetcher.state !== "idle") { + customizeSubmitSeenRef.current = true; + return; + } + if (!customizeSubmitSeenRef.current) return; // submit hasn't been picked up yet + const data = customizationFetcher.data; + if (!data) return; + if (data.success) { + // Wait out the post-save revalidation so the menu behind the dialog reflects the changes + if (revalidator.state !== "idle") return; + setCustomizeConfirmPending(false); + setCustomizeOpen(false); + } else { + setCustomizeConfirmPending(false); + setCustomizeError("Couldn't save your changes. Please try again."); + } + }, [ + isCustomizeConfirmPending, + customizationFetcher.state, + customizationFetcher.data, + revalidator.state, + ]); // Same ownership rule: removing a favorite optimistically unmounts its menu item (and, for the // last favorite, the whole section), which would abort an item-owned fetcher mid-request. // Separate fetchers per mutation: fetchers are single-flight, so a shared one would cancel an @@ -1221,7 +1257,18 @@ export function SideMenu({
- + { + setCustomizeOpen(open); + if (!open) { + // Cancel/ESC during a pending confirm abandons the wait; a still-running save is + // harmless (the menu revalidates whenever it lands) + setCustomizeConfirmPending(false); + setCustomizeError(undefined); + } + }} + > {/* Mounted only while open so the modal state re-seeds from current preferences each time */} {isCustomizeOpen && ( setCustomizeOpen(false)} + isConfirming={isCustomizeConfirmPending} + confirmError={customizeError} /> )} diff --git a/apps/webapp/app/routes/resources.preferences.favorites.tsx b/apps/webapp/app/routes/resources.preferences.favorites.tsx index 5005ca096fc..071d2644939 100644 --- a/apps/webapp/app/routes/resources.preferences.favorites.tsx +++ b/apps/webapp/app/routes/resources.preferences.favorites.tsx @@ -5,6 +5,7 @@ import { removeFavorite, renameFavorite, } from "~/services/dashboardPreferences.server"; +import { logger } from "~/services/logger.server"; import { requireUser } from "~/services/session.server"; const FavoriteLabel = z @@ -52,20 +53,27 @@ export async function action({ request }: ActionFunctionArgs) { return json({ success: false, error: "Invalid request data" }, { status: 400 }); } - 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; + // 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 }); diff --git a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx index 11757164f76..277dc3e01c1 100644 --- a/apps/webapp/app/routes/resources.preferences.sidemenu.tsx +++ b/apps/webapp/app/routes/resources.preferences.sidemenu.tsx @@ -9,6 +9,7 @@ import { 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 @@ -67,14 +68,25 @@ export async function action({ request }: ActionFunctionArgs) { } const { sectionOrder, hiddenItems, sectionItemOrder, favorites, removedFavoriteIds } = customizationResult.data; - await updateSideMenuCustomization({ - user, - sectionOrder, - hiddenItems, - sectionItemOrder, - favorites, - removedFavoriteIds, - }); + // 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 }); } diff --git a/apps/webapp/app/services/dashboardPreferences.server.ts b/apps/webapp/app/services/dashboardPreferences.server.ts index d2749c08e4c..9f5f84fe2e8 100644 --- a/apps/webapp/app/services/dashboardPreferences.server.ts +++ b/apps/webapp/app/services/dashboardPreferences.server.ts @@ -93,28 +93,36 @@ async function mutateDashboardPreferences( userId: string, mutate: (current: DashboardPreferences) => DashboardPreferences | undefined ) { - return await $transaction(prisma, "mutateDashboardPreferences", async (tx) => { - const rows = await tx.$queryRaw>` + return await $transaction( + prisma, + "mutateDashboardPreferences", + async (tx) => { + const rows = await tx.$queryRaw>` SELECT "dashboardPreferences" FROM "User" WHERE id = ${userId} FOR UPDATE `; - if (rows.length === 0) { - return undefined; - } + if (rows.length === 0) { + return undefined; + } - const updated = mutate(getDashboardPreferences(rows[0].dashboardPreferences)); - if (!updated) { - return undefined; - } + const updated = mutate(getDashboardPreferences(rows[0].dashboardPreferences)); + if (!updated) { + return undefined; + } - return await tx.user.update({ - where: { - id: userId, - }, - data: { - dashboardPreferences: updated, - }, - }); - }); + 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({ From 8a2eb704e330c8e29b86290aee5e740148117ff2 Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 27 Jul 2026 11:52:34 +0100 Subject: [PATCH 16/18] fix(webapp): stop favorites capturing pagination position in their URLs Favoriting a paged view saved the cursor, direction, and page params, so the favorite linked to a soon-stale slice of results, only lit up at that exact cursor, and starring the same view from another page created a duplicate. Saved and matched favorite URLs now drop the pagination position via a shared canonicalizer, so a favorite pins the view's filters and tabs, and paging within a favorited view keeps it active. The span param stays: it pins a trace view selection that lives as long as the favorited run page itself. Both sides of the match are canonicalized so favorites saved before this keep working. --- .../navigation/FavoritePageButton.tsx | 10 +++--- .../components/navigation/favoritePages.tsx | 34 ++++++++++++++----- 2 files changed, 32 insertions(+), 12 deletions(-) diff --git a/apps/webapp/app/components/navigation/FavoritePageButton.tsx b/apps/webapp/app/components/navigation/FavoritePageButton.tsx index 14a485294a9..dea4ab82a2f 100644 --- a/apps/webapp/app/components/navigation/FavoritePageButton.tsx +++ b/apps/webapp/app/components/navigation/FavoritePageButton.tsx @@ -11,10 +11,11 @@ import { useShortcuts } from "../primitives/ShortcutsProvider"; import { SimpleTooltip } from "../primitives/Tooltip"; import { buildFavoriteLabel, + canonicalFavoriteUrl, FAVORITE_SEARCH_PARAM, FAVORITES_ACTION_PATH, + favoritePageUrl, resolvePageMeta, - stripFavoriteSearchParam, useFavorites, } from "./favoritePages"; @@ -37,8 +38,9 @@ export function FavoritePageButton({ const { areShortcutsEnabled } = useShortcuts(); const [, setSearchParams] = useSearchParams(); - // The favorite marker param is presentation-only, so it never counts toward URL identity - const url = location.pathname + stripFavoriteSearchParam(location.search); + // 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. @@ -57,7 +59,7 @@ export function FavoritePageButton({ { replace: true, preventScrollReset: true } ); }, [hasForeignMarker, setSearchParams]); - const existing = favorites.find((favorite) => favorite.url === url); + 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") diff --git a/apps/webapp/app/components/navigation/favoritePages.tsx b/apps/webapp/app/components/navigation/favoritePages.tsx index 58e6e05c7a9..4439aadedfd 100644 --- a/apps/webapp/app/components/navigation/favoritePages.tsx +++ b/apps/webapp/app/components/navigation/favoritePages.tsx @@ -134,18 +134,36 @@ export function favoriteLinkTo(favorite: FavoritePage): string { return `${path}?${params.toString()}`; } -/** The current location's search string without the favorite marker, for saving/matching URLs. */ -export function stripFavoriteSearchParam(search: string): string { +/** 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 result.length > 0 ? `?${result}` : ""; + 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 exactly the view it saved: its marker param is - * present AND the rest of the 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). + * 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, @@ -154,7 +172,7 @@ export function isFavoriteActive( ): boolean { return ( new URLSearchParams(search).get(FAVORITE_SEARCH_PARAM) === favorite.id && - favorite.url === pathname + stripFavoriteSearchParam(search) + canonicalFavoriteUrl(favorite.url) === favoritePageUrl(pathname, search) ); } @@ -344,7 +362,7 @@ function humanizeValue(value: string): string { } /** Pagination/UI-state params that never describe what the user filtered. */ -const NON_FILTER_PARAMS = [FAVORITE_SEARCH_PARAM, "cursor", "direction", "page", "span"]; +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 From 929a6f964cc7f2e4fe70ee56a19ef0adda5b68bc Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 27 Jul 2026 13:52:04 +0100 Subject: [PATCH 17/18] refactor(webapp): capture Option+F with the standard shortcuts hook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The favorite toggle used a raw document keydown listener because Option+F reports event.key as an Option-transformed symbol on macOS ("ƒ"), which an event.key comparison cannot match. The hotkeys library behind useShortcutKeys also matches the physical event.code, so the standard hook captures Option+letter after all. Behavior is unchanged: exact modifier matching keeps the bare f shortcut separate, inputs and content-editable elements are still skipped, and the global shortcuts toggle is respected. Held keys no longer repeat the toggle. --- .../navigation/FavoritePageButton.tsx | 50 +++++-------------- 1 file changed, 12 insertions(+), 38 deletions(-) diff --git a/apps/webapp/app/components/navigation/FavoritePageButton.tsx b/apps/webapp/app/components/navigation/FavoritePageButton.tsx index dea4ab82a2f..c0d03453b38 100644 --- a/apps/webapp/app/components/navigation/FavoritePageButton.tsx +++ b/apps/webapp/app/components/navigation/FavoritePageButton.tsx @@ -1,13 +1,13 @@ 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, useRef } from "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 { useShortcuts } from "../primitives/ShortcutsProvider"; import { SimpleTooltip } from "../primitives/Tooltip"; import { buildFavoriteLabel, @@ -35,7 +35,6 @@ export function FavoritePageButton({ const location = useLocation(); const favorites = useFavorites(); const fetcher = useFetcher(); - const { areShortcutsEnabled } = useShortcuts(); const [, setSearchParams] = useSearchParams(); // The marker param and pagination position never count toward URL identity, so paging through @@ -86,44 +85,19 @@ export function FavoritePageButton({ } }; - // The listener reads the latest toggle through a ref so it isn't re-attached every render - const toggleRef = useRef(toggle); - toggleRef.current = toggle; - const showButton = user !== undefined && !isImpersonating; - useEffect(() => { - if (!showButton || !areShortcutsEnabled) return; - - const onKeyDown = (event: KeyboardEvent) => { - // Matched on `event.code`: on macOS, Option makes "F" report "ƒ" via `event.key`, so the - // event.key-based useShortcutKeys hook can't capture Option+letter (see GlobalShortcuts). - if ( - event.code !== "KeyF" || - !event.altKey || - event.metaKey || - event.ctrlKey || - event.shiftKey - ) { - return; - } - const target = event.target; - if ( - target instanceof HTMLElement && - (target.tagName === "INPUT" || - target.tagName === "TEXTAREA" || - target.tagName === "SELECT" || - target.isContentEditable) - ) { - return; - } + // 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(); - toggleRef.current(); - }; - - document.addEventListener("keydown", onKeyDown); - return () => document.removeEventListener("keydown", onKeyDown); - }, [showButton, areShortcutsEnabled]); + toggle(); + }, + disabled: !showButton, + }); if (!showButton) { return null; From d50f422dcace1ab627938b77db2109979532a6ba Mon Sep 17 00:00:00 2001 From: James Ritchie Date: Mon, 27 Jul 2026 14:33:39 +0100 Subject: [PATCH 18/18] fix(webapp): address review feedback on favorites active state and confirm settling Two fixes from review. Arriving on a favorited org settings or account page via its favorite left that page's side menu with nothing highlighted: the shared menu item suppressed its active state whenever a favorite owned the view, but those menus never render the favorites list that would carry the highlight instead. Yielding the active state to a favorite is now opt-in, set only by the main project menu and its dashboards list. Also, if the customize sidebar save settled with no response body (for example a session-expiry redirect), the Confirm button could spin forever; that case now surfaces the save error instead. --- .../app/components/navigation/DashboardList.tsx | 1 + .../app/components/navigation/SideMenu.tsx | 11 ++++++++++- .../app/components/navigation/SideMenuItem.tsx | 16 +++++++++++++--- 3 files changed, 24 insertions(+), 4 deletions(-) diff --git a/apps/webapp/app/components/navigation/DashboardList.tsx b/apps/webapp/app/components/navigation/DashboardList.tsx index 7f3b6e3bacb..39a7de00c1d 100644 --- a/apps/webapp/app/components/navigation/DashboardList.tsx +++ b/apps/webapp/app/components/navigation/DashboardList.tsx @@ -184,6 +184,7 @@ function DashboardChildMenuItem({ to={item.path} isCollapsed={isCollapsed} disableIconHover + yieldActiveToFavorite action={ showDragHandle ? (
diff --git a/apps/webapp/app/components/navigation/SideMenu.tsx b/apps/webapp/app/components/navigation/SideMenu.tsx index dd5ebcfa4e6..1aa9da19f24 100644 --- a/apps/webapp/app/components/navigation/SideMenu.tsx +++ b/apps/webapp/app/components/navigation/SideMenu.tsx @@ -424,7 +424,12 @@ export function SideMenu({ } if (!customizeSubmitSeenRef.current) return; // submit hasn't been picked up yet const data = customizationFetcher.data; - if (!data) return; + if (!data) { + // Settled with no response body (e.g. a session-expiry redirect): fail rather than spin + setCustomizeConfirmPending(false); + setCustomizeError("Couldn't save your changes. Please try again."); + return; + } if (data.success) { // Wait out the post-save revalidation so the menu behind the dialog reflects the changes if (revalidator.state !== "idle") return; @@ -1152,6 +1157,7 @@ export function SideMenu({ to={v3EnvironmentPath(organization, project, environment)} data-action="tasks" isCollapsed={isCollapsed} + yieldActiveToFavorite /> } isCollapsed={isCollapsed} + yieldActiveToFavorite />
@@ -1334,6 +1342,7 @@ function CustomizableSideMenuSection({ badge={item.badge} isCollapsed={isCollapsed} action={item.action} + yieldActiveToFavorite /> {item.after} diff --git a/apps/webapp/app/components/navigation/SideMenuItem.tsx b/apps/webapp/app/components/navigation/SideMenuItem.tsx index fc5a431837c..035fcc54370 100644 --- a/apps/webapp/app/components/navigation/SideMenuItem.tsx +++ b/apps/webapp/app/components/navigation/SideMenuItem.tsx @@ -85,6 +85,7 @@ export function SideMenuItem({ disableIconHover = false, indented = false, isActive: isActiveOverride, + yieldActiveToFavorite = false, "data-action": dataAction, }: { icon?: RenderIcon; @@ -105,13 +106,22 @@ export function SideMenuItem({ 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(); - // When one of the user's OWN favorites owns the current view (via its marker param), it takes - // the active state; markers from shared links don't count (see useActiveFavoriteId). + // 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 && activeFavoriteId === undefined); + const isActive = + isActiveOverride ?? + (pathName === to && (!yieldActiveToFavorite || activeFavoriteId === undefined)); const isIndented = indented && !isCollapsed;