mirror of
https://github.com/OpenHands/OpenHands.git
synced 2026-10-07 16:19:05 +08:00
feat: pin a favorite sidebar page as the home route (#16868)
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 个字符",
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user