feat: pin a favorite sidebar page as the home route (#16868)

This commit is contained in:
Hiep Le
2026-08-25 18:55:19 +07:00
committed by GitHub
parent a3652270f6
commit f48eca6ab9
8 changed files with 382 additions and 2 deletions
@@ -15,6 +15,7 @@ import {
NavigationProvider,
type NavigationContextValue,
} from "#/context/navigation-context";
import { getPinnedHomeRouteKey } from "#/hooks/use-pinned-home-route";
import translations from "#/i18n/translation.json";
// The global `useTranslation` mock in `vitest.setup.ts` returns the key
@@ -507,4 +508,33 @@ describe("Sidebar", () => {
"Automate",
);
});
it("pins and unpins a sidebar page as the home route without navigating", () => {
// Arrange: the mocked active backend is `local` with no org.
const pinKey = getPinnedHomeRouteKey("local", null);
const { navigate } = renderSidebar("/conversations");
const pinToggle = screen.getByTestId("sidebar-pin-home-toggle-customize");
expect(pinToggle).toHaveAttribute("aria-pressed", "false");
expect(pinToggle).toHaveAttribute("aria-label", "Pin as home page");
// Act: pin Customize as the home page.
fireEvent.click(pinToggle);
// Assert: the toggle flips and the pin persists for the active backend.
expect(pinToggle).toHaveAttribute("aria-pressed", "true");
expect(pinToggle).toHaveAttribute("aria-label", "Unpin as home page");
expect(window.localStorage.getItem(pinKey)).toBe(
JSON.stringify("/customize"),
);
expect(navigate).not.toHaveBeenCalled();
// Act: unpin from the same control.
fireEvent.click(pinToggle);
// Assert: the pin is gone.
expect(pinToggle).toHaveAttribute("aria-pressed", "false");
expect(window.localStorage.getItem(pinKey)).not.toBe(
JSON.stringify("/customize"),
);
});
});
@@ -0,0 +1,19 @@
import { describe, expect, it } from "vitest";
import {
getPinnedHomeRouteKey,
PINNED_HOME_ROUTE_KEY,
} from "#/hooks/use-pinned-home-route";
describe("getPinnedHomeRouteKey", () => {
it("scopes the storage key by backend and org", () => {
expect(getPinnedHomeRouteKey("backend-a", "org-1")).toBe(
`${PINNED_HOME_ROUTE_KEY}:backend-a:org-1`,
);
expect(getPinnedHomeRouteKey("backend-a", null)).toBe(
`${PINNED_HOME_ROUTE_KEY}:backend-a:-`,
);
expect(getPinnedHomeRouteKey("backend-a", "org-1")).not.toBe(
getPinnedHomeRouteKey("backend-b", "org-1"),
);
});
});
@@ -0,0 +1,94 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
__resetActiveStoreForTests,
setActiveSelection,
setRegisteredBackends,
} from "#/api/backend-registry/active-store";
import type { Backend } from "#/api/backend-registry/types";
import { getPinnedHomeRouteKey } from "#/hooks/use-pinned-home-route";
import { clientLoader } from "#/routes/index-home";
/**
* The automation interface manifest is withheld so the "pinned page's
* feature is no longer available" case is exercisable; /customize has no
* such gate and stays pinnable.
*/
vi.mock("#/manifests/manifest-sources", async (importOriginal) => {
const actual =
await importOriginal<typeof import("#/manifests/manifest-sources")>();
return { ...actual, AUTOMATION_INTERFACE_CANDIDATE: undefined };
});
// Only the loader is under test; keep the home screen's tree out of it.
vi.mock("#/routes/home", () => ({ default: () => null }));
const BACKEND: Backend = {
id: "backend-a",
name: "Backend A",
host: "http://localhost:3000",
apiKey: "",
kind: "local",
};
const PIN_KEY = getPinnedHomeRouteKey(BACKEND.id, null);
describe("the index route with a pinned home page", () => {
beforeEach(() => {
window.localStorage.clear();
setRegisteredBackends([BACKEND]);
setActiveSelection({ backendId: BACKEND.id });
});
afterEach(() => {
window.localStorage.clear();
window.sessionStorage.clear();
__resetActiveStoreForTests();
});
it("redirects / to the pinned page of the active backend", () => {
// Arrange
window.localStorage.setItem(PIN_KEY, JSON.stringify("/customize"));
// Act
const result = clientLoader();
// Assert
expect(result).toBeInstanceOf(Response);
expect((result as Response).headers.get("Location")).toBe("/customize");
});
it("renders the default home when no pin is set", () => {
expect(clientLoader()).toBeNull();
});
it("falls back to the default home when the stored pin does not resolve, leaving the value intact", () => {
for (const rawValue of ['"/"', '"/nonexistent"', "not-json"]) {
// Arrange
window.localStorage.setItem(PIN_KEY, rawValue);
// Act & Assert
expect(clientLoader()).toBeNull();
expect(window.localStorage.getItem(PIN_KEY)).toBe(rawValue);
}
});
it("falls back to the default home when the pinned page's interface is absent", () => {
// Arrange: /automations was pinnable when set, but this deployment has
// no admitted automation interface manifest.
window.localStorage.setItem(PIN_KEY, JSON.stringify("/automations"));
// Act & Assert
expect(clientLoader()).toBeNull();
});
it("ignores a pin stored for a different backend", () => {
// Arrange
window.localStorage.setItem(
getPinnedHomeRouteKey("backend-b", null),
JSON.stringify("/customize"),
);
// Act & Assert
expect(clientLoader()).toBeNull();
});
});
@@ -1,7 +1,9 @@
import React from "react";
import { Pin } from "lucide-react";
import { NavigationLink } from "#/components/shared/navigation-link";
import { StyledTooltip } from "#/components/shared/buttons/styled-tooltip";
import { useNavigation } from "#/context/navigation-context";
import { hoverRevealActionClassName } from "#/utils/hover-reveal-classes";
import { cn } from "#/utils/utils";
import { SidebarCollapsedIconSlot } from "./sidebar-collapsed-icon-slot";
import {
@@ -23,6 +25,14 @@ function isPathActive(currentPath: string, to: string, end: boolean) {
return currentPath === to || currentPath.startsWith(`${to}/`);
}
export interface SidebarNavLinkPinAction {
pinned: boolean;
onToggle: () => void;
/** Localized aria-label; pin vs unpin variants resolved by the caller. */
label: string;
testId: string;
}
interface SidebarNavLinkProps {
to: string;
label: string;
@@ -39,6 +49,8 @@ interface SidebarNavLinkProps {
* (e.g. the Extensions link being active on /mcp and /plugins too).
*/
forceActive?: boolean;
/** Pin-as-home toggle; rendered only when the sidebar is expanded. */
pinAction?: SidebarNavLinkPinAction;
}
export function SidebarNavLink({
@@ -52,9 +64,11 @@ export function SidebarNavLink({
collapsed = false,
hoverContent,
forceActive = false,
pinAction,
}: SidebarNavLinkProps) {
const { currentPath } = useNavigation();
const active = forceActive || isPathActive(currentPath, to, end);
const showPinAction = !collapsed && pinAction != null;
const link = (
<NavigationLink
@@ -81,6 +95,8 @@ export function SidebarNavLink({
: SIDEBAR_ROW_INTERACTIVE_CLASS.idle),
disabled && "opacity-50",
disabled && "pointer-events-none",
// Constant right reserve so label truncation doesn't reflow on hover.
showPinAction && "pr-9",
)}
>
{icon ? (
@@ -96,7 +112,40 @@ export function SidebarNavLink({
</NavigationLink>
);
if (!collapsed) return link;
if (!collapsed) {
if (!pinAction) return link;
// NavigationLink renders an <a>, so the pin toggle is an absolutely
// positioned sibling rather than a nested button. Bare `group` is safe
// here: only collapsed rows use it, and this branch is expanded-only.
return (
<div className="group relative">
{link}
<button
type="button"
data-testid={pinAction.testId}
aria-pressed={pinAction.pinned}
aria-label={pinAction.label}
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
pinAction.onToggle();
}}
className={cn(
"absolute right-1.5 top-1/2 -translate-y-1/2",
"flex shrink-0 cursor-pointer items-center justify-center rounded-md p-1",
"text-[var(--oh-muted)] hover:bg-white/10 hover:text-white",
hoverRevealActionClassName(pinAction.pinned),
)}
>
<Pin
className={cn("h-3.5 w-3.5", pinAction.pinned && "fill-current")}
aria-hidden
/>
</button>
</div>
);
}
return (
<StyledTooltip
@@ -14,6 +14,10 @@ import {
getInterfaceCopy,
hasAutomationInterface,
} from "#/manifests/automation-interface";
import {
CUSTOMIZE_PATH,
usePinnedHomeRoute,
} from "#/hooks/use-pinned-home-route";
import { SidebarCollapsedIconSlot } from "./sidebar-collapsed-icon-slot";
import { SidebarNavLink } from "./sidebar-nav-link";
import { I18nKey } from "#/i18n/declaration";
@@ -84,6 +88,19 @@ export function SidebarRailBody({
}: SidebarRailBodyProps) {
const { t } = useTranslation("openhands");
const backendCloseTimerRef = collapsedBackendCloseTimer;
const { isPinnedRoute, togglePinnedRoute } = usePinnedHomeRoute();
const buildPinAction = (path: string, testId: string) => {
const pinned = isPinnedRoute(path);
return {
pinned,
onToggle: () => togglePinnedRoute(path),
label: pinned
? t(I18nKey.SIDEBAR$UNPIN_AS_HOME)
: t(I18nKey.SIDEBAR$PIN_AS_HOME),
testId,
};
};
return (
<div className="flex min-h-0 flex-1 flex-col">
@@ -172,11 +189,15 @@ export function SidebarRailBody({
icon={<Plus width={ICON_SIZE} height={ICON_SIZE} />}
/>
<SidebarNavLink
to="/customize"
to={CUSTOMIZE_PATH}
label={t(I18nKey.NAV$CUSTOMIZE)}
testId="sidebar-skills-link"
collapsed={collapsed}
forceActive={isExtensionsActive}
pinAction={buildPinAction(
CUSTOMIZE_PATH,
"sidebar-pin-home-toggle-customize",
)}
icon={
<svg
xmlns="http://www.w3.org/2000/svg"
@@ -214,6 +235,10 @@ export function SidebarRailBody({
testId="sidebar-automations-link"
collapsed={collapsed}
icon={<AutomationsIcon width={ICON_SIZE} height={ICON_SIZE} />}
pinAction={buildPinAction(
automationListPath(),
"sidebar-pin-home-toggle-automations",
)}
/>
)}
</nav>
+113
View File
@@ -0,0 +1,113 @@
import { useLocalStorage } from "@uidotdev/usehooks";
import { useCallback, useMemo } from "react";
import {
getActiveBackend,
isNoBackend,
} from "#/api/backend-registry/active-store";
import { useActiveBackend } from "#/contexts/active-backend-context";
import {
automationListPath,
hasAutomationInterface,
} from "#/manifests/automation-interface";
export const PINNED_HOME_ROUTE_KEY = "oh:pinned-home-route";
/** Sidebar Customize entry; also the pin target, so the two cannot drift. */
export const CUSTOMIZE_PATH = "/customize";
/**
* The pin is stored per backend + org: it may reference a surface that only
* exists on the backend that set it (e.g. /automations requires that
* deployment's interface manifest) — a shared key would let one backend
* redirect `/` on another.
*/
export function getPinnedHomeRouteKey(
backendId: string,
orgId: string | null,
): string {
return `${PINNED_HOME_ROUTE_KEY}:${backendId}:${orgId ?? "-"}`;
}
/**
* Whether `path` may serve as the home route right now. Shared by the
* sidebar pin affordance and the `/` loader, so a stored pin that stops
* resolving (backend switch, manifest absent) is ignored rather than an
* error. `/` is never pinnable, which makes a redirect loop impossible.
* Canvas Extensions pages can later add an `/extensions/…` branch here
* without any storage or loader change.
*/
export function isPinnableRoute(path: string): boolean {
if (path === CUSTOMIZE_PATH) return true;
if (path === automationListPath()) return hasAutomationInterface();
return false;
}
function sanitizePinnedRoute(value: unknown): string | null {
if (typeof value !== "string" || !value) return null;
return isPinnableRoute(value) ? value : null;
}
/**
* Synchronous pin read for route loaders (no React context). Reads the key
* `usePinnedHomeRoute` writes; `useLocalStorage` JSON-serializes values, so
* parse defensively and treat anything unreadable as "no pin". An invalid
* pin is ignored, not cleared — it may become valid again (e.g. the
* automations interface returning after a backend switch back).
*/
export function readPinnedHomeRoute(): string | null {
const active = getActiveBackend();
if (isNoBackend(active.backend)) return null;
let raw: string | null = null;
try {
raw = window.localStorage.getItem(
getPinnedHomeRouteKey(active.backend.id, active.orgId),
);
} catch {
return null;
}
if (raw === null) return null;
try {
return sanitizePinnedRoute(JSON.parse(raw));
} catch {
return null;
}
}
/** Pin state for the home route: `/` redirects to the pinned sidebar page. */
export function usePinnedHomeRoute() {
const active = useActiveBackend();
const [rawPinnedRoute, setRawPinnedRoute] = useLocalStorage<string | null>(
getPinnedHomeRouteKey(active.backend.id, active.orgId),
null,
);
const pinnedRoute = useMemo(
() => sanitizePinnedRoute(rawPinnedRoute),
[rawPinnedRoute],
);
const isPinnedRoute = useCallback(
(path: string) => pinnedRoute === path,
[pinnedRoute],
);
const togglePinnedRoute = useCallback(
(path: string) => {
if (pinnedRoute === path) {
setRawPinnedRoute(null);
return;
}
if (!isPinnableRoute(path)) return;
setRawPinnedRoute(path);
},
[pinnedRoute, setRawPinnedRoute],
);
return {
pinnedRoute,
isPinnedRoute,
togglePinnedRoute,
};
}
+34
View File
@@ -35937,6 +35937,40 @@
"uk": "Новий чат",
"ca": "Xat nou"
},
"SIDEBAR$PIN_AS_HOME": {
"en": "Pin as home page",
"zh-CN": "固定为主页",
"zh-TW": "釘選為首頁",
"de": "Als Startseite anheften",
"ko-KR": "홈 페이지로 고정",
"no": "Pin as home page",
"it": "Fissa come pagina iniziale",
"pt": "Fixar como página inicial",
"es": "Fijar como página de inicio",
"ar": "Pin as home page",
"fr": "Épingler comme page d'accueil",
"tr": "Pin as home page",
"ja": "ホームページとしてピン留め",
"uk": "Pin as home page",
"ca": "Pin as home page"
},
"SIDEBAR$UNPIN_AS_HOME": {
"en": "Unpin as home page",
"zh-CN": "取消固定为主页",
"zh-TW": "取消釘選為首頁",
"de": "Als Startseite lösen",
"ko-KR": "홈 페이지 고정 해제",
"no": "Unpin as home page",
"it": "Rimuovi come pagina iniziale",
"pt": "Desafixar como página inicial",
"es": "Quitar como página de inicio",
"ar": "Unpin as home page",
"fr": "Désépingler comme page d'accueil",
"tr": "Unpin as home page",
"ja": "ホームページのピン留め解除",
"uk": "Unpin as home page",
"ca": "Unpin as home page"
},
"SETTINGS$SECRET_NAME_PATTERN_TITLE": {
"en": "Must start with a letter, contain only letters/numbers/underscores, and be 1-64 characters",
"zh-CN": "必须以字母开头,只能包含字母/数字/下划线,且长度为 1-64 个字符",
+16
View File
@@ -1 +1,17 @@
import { redirect } from "react-router";
import { readPinnedHomeRoute } from "#/hooks/use-pinned-home-route";
/**
* With a home pin set for the active backend + org, `/` redirects to the
* pinned page. `readPinnedHomeRoute` only returns routes that currently
* resolve (never `/` itself), so a stale pin falls back to the default
* home with no error and no redirect loop; the built-in home stays
* reachable unmodified at /conversations.
*/
export const clientLoader = () => {
const pinnedRoute = readPinnedHomeRoute();
if (pinnedRoute) return redirect(pinnedRoute);
return null;
};
export { default } from "./home";