From 9b834bf660b6cb98efd735dc4c534d0b6c65da0a Mon Sep 17 00:00:00 2001 From: "sp.wack" <83104063+amanape@users.noreply.github.com> Date: Mon, 5 Jan 2026 23:17:53 +0400 Subject: [PATCH] feat(frontend): create `useAppTitle` hook for dynamic document titles (#12224) --- .../use-document-title-from-state.test.tsx | 135 ------------------ frontend/src/hooks/use-app-title.test.tsx | 105 ++++++++++++++ frontend/src/hooks/use-app-title.ts | 26 ++++ .../hooks/use-document-title-from-state.ts | 26 ---- frontend/src/routes/conversation.tsx | 5 - frontend/src/routes/root-layout.tsx | 3 + 6 files changed, 134 insertions(+), 166 deletions(-) delete mode 100644 frontend/__tests__/hooks/use-document-title-from-state.test.tsx create mode 100644 frontend/src/hooks/use-app-title.test.tsx create mode 100644 frontend/src/hooks/use-app-title.ts delete mode 100644 frontend/src/hooks/use-document-title-from-state.ts diff --git a/frontend/__tests__/hooks/use-document-title-from-state.test.tsx b/frontend/__tests__/hooks/use-document-title-from-state.test.tsx deleted file mode 100644 index c7fa893975..0000000000 --- a/frontend/__tests__/hooks/use-document-title-from-state.test.tsx +++ /dev/null @@ -1,135 +0,0 @@ -import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; -import { renderHook } from "@testing-library/react"; -import { useDocumentTitleFromState } from "#/hooks/use-document-title-from-state"; -import { useActiveConversation } from "#/hooks/query/use-active-conversation"; - -// Mock the useActiveConversation hook -vi.mock("#/hooks/query/use-active-conversation"); - -const mockUseActiveConversation = vi.mocked(useActiveConversation); - -describe("useDocumentTitleFromState", () => { - const originalTitle = document.title; - - beforeEach(() => { - vi.clearAllMocks(); - document.title = "Test"; - }); - - afterEach(() => { - document.title = originalTitle; - vi.resetAllMocks(); - }); - - it("should set document title to default suffix when no conversation", () => { - mockUseActiveConversation.mockReturnValue({ - data: null, - } as any); - - renderHook(() => useDocumentTitleFromState()); - - expect(document.title).toBe("OpenHands"); - }); - - it("should set document title to custom suffix when no conversation", () => { - mockUseActiveConversation.mockReturnValue({ - data: null, - } as any); - - renderHook(() => useDocumentTitleFromState("Custom App")); - - expect(document.title).toBe("Custom App"); - }); - - it("should set document title with conversation title", () => { - mockUseActiveConversation.mockReturnValue({ - data: { - conversation_id: "123", - title: "My Conversation", - status: "RUNNING", - }, - } as any); - - renderHook(() => useDocumentTitleFromState()); - - expect(document.title).toBe("My Conversation | OpenHands"); - }); - - it("should update document title when conversation title changes", () => { - // Initial state - no conversation - mockUseActiveConversation.mockReturnValue({ - data: null, - } as any); - - const { rerender } = renderHook(() => useDocumentTitleFromState()); - expect(document.title).toBe("OpenHands"); - - // Conversation with initial title - mockUseActiveConversation.mockReturnValue({ - data: { - conversation_id: "123", - title: "Conversation 65e29", - status: "RUNNING", - }, - } as any); - rerender(); - expect(document.title).toBe("Conversation 65e29 | OpenHands"); - - // Conversation title updated to human-readable title - mockUseActiveConversation.mockReturnValue({ - data: { - conversation_id: "123", - title: "Help me build a React app", - status: "RUNNING", - }, - } as any); - rerender(); - expect(document.title).toBe("Help me build a React app | OpenHands"); - }); - - it("should handle conversation without title", () => { - mockUseActiveConversation.mockReturnValue({ - data: { - conversation_id: "123", - title: undefined, - status: "RUNNING", - }, - } as any); - - renderHook(() => useDocumentTitleFromState()); - - expect(document.title).toBe("OpenHands"); - }); - - it("should handle empty conversation title", () => { - mockUseActiveConversation.mockReturnValue({ - data: { - conversation_id: "123", - title: "", - status: "RUNNING", - }, - } as any); - - renderHook(() => useDocumentTitleFromState()); - - expect(document.title).toBe("OpenHands"); - }); - - it("should reset document title on cleanup", () => { - mockUseActiveConversation.mockReturnValue({ - data: { - conversation_id: "123", - title: "My Conversation", - status: "RUNNING", - }, - } as any); - - const { unmount } = renderHook(() => useDocumentTitleFromState()); - - expect(document.title).toBe("My Conversation | OpenHands"); - - unmount(); - - expect(document.title).toBe("OpenHands"); - }); -}); diff --git a/frontend/src/hooks/use-app-title.test.tsx b/frontend/src/hooks/use-app-title.test.tsx new file mode 100644 index 0000000000..440b857779 --- /dev/null +++ b/frontend/src/hooks/use-app-title.test.tsx @@ -0,0 +1,105 @@ +import { renderHook, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { useParams } from "react-router"; +import OptionService from "#/api/option-service/option-service.api"; +import { useUserConversation } from "./query/use-user-conversation"; +import { useAppTitle } from "./use-app-title"; + +const renderAppTitleHook = () => + renderHook(() => useAppTitle(), { + wrapper: ({ children }) => ( + + {children} + + ), + }); + +vi.mock("./query/use-user-conversation"); +vi.mock("react-router", async () => { + const actual = await vi.importActual("react-router"); + return { + ...actual, + useParams: vi.fn(), + }; +}); + +describe("useAppTitle", () => { + const getConfigSpy = vi.spyOn(OptionService, "getConfig"); + const mockUseUserConversation = vi.mocked(useUserConversation); + const mockUseParams = vi.mocked(useParams); + + beforeEach(() => { + // @ts-expect-error - only returning partial config for test + mockUseUserConversation.mockReturnValue({ data: null }); + mockUseParams.mockReturnValue({}); + }); + + it("should return 'OpenHands' if is OSS and NOT in /conversations", async () => { + // @ts-expect-error - only returning partial config for test + getConfigSpy.mockResolvedValue({ + APP_MODE: "oss", + }); + + const { result } = renderAppTitleHook(); + + await waitFor(() => expect(result.current).toBe("OpenHands")); + }); + + it("should return 'OpenHands Cloud' if is SaaS and NOT in /conversations", async () => { + // @ts-expect-error - only returning partial config for test + getConfigSpy.mockResolvedValue({ + APP_MODE: "saas", + }); + + const { result } = renderAppTitleHook(); + + await waitFor(() => expect(result.current).toBe("OpenHands Cloud")); + }); + + it("should return '{some title} | OpenHands' if is OSS and in /conversations", async () => { + // @ts-expect-error - only returning partial config for test + getConfigSpy.mockResolvedValue({ APP_MODE: "oss" }); + mockUseParams.mockReturnValue({ conversationId: "123" }); + mockUseUserConversation.mockReturnValue({ + // @ts-expect-error - only returning partial config for test + data: { title: "My Conversation" }, + }); + + const { result } = renderAppTitleHook(); + + await waitFor(() => + expect(result.current).toBe("My Conversation | OpenHands"), + ); + }); + + it("should return '{some title} | OpenHands Cloud' if is SaaS and in /conversations", async () => { + // @ts-expect-error - only returning partial config for test + getConfigSpy.mockResolvedValue({ APP_MODE: "saas" }); + mockUseParams.mockReturnValue({ conversationId: "456" }); + mockUseUserConversation.mockReturnValue({ + // @ts-expect-error - only returning partial config for test + data: { title: "Another Conversation Title" }, + }); + + const { result } = renderAppTitleHook(); + + await waitFor(() => + expect(result.current).toBe( + "Another Conversation Title | OpenHands Cloud", + ), + ); + }); + + it("should return app name while conversation is loading", async () => { + // @ts-expect-error - only returning partial config for test + getConfigSpy.mockResolvedValue({ APP_MODE: "oss" }); + mockUseParams.mockReturnValue({ conversationId: "123" }); + // @ts-expect-error - only returning partial config for test + mockUseUserConversation.mockReturnValue({ data: undefined }); + + const { result } = renderAppTitleHook(); + + await waitFor(() => expect(result.current).toBe("OpenHands")); + }); +}); diff --git a/frontend/src/hooks/use-app-title.ts b/frontend/src/hooks/use-app-title.ts new file mode 100644 index 0000000000..15ef49b486 --- /dev/null +++ b/frontend/src/hooks/use-app-title.ts @@ -0,0 +1,26 @@ +import { useParams } from "react-router"; +import { useConfig } from "#/hooks/query/use-config"; +import { useUserConversation } from "#/hooks/query/use-user-conversation"; + +const APP_TITLE_OSS = "OpenHands"; +const APP_TITLE_SAAS = "OpenHands Cloud"; + +/** + * Hook that returns the appropriate document title based on APP_MODE and current route. + * - For conversation pages: "Conversation Title | OpenHands" or "Conversation Title | OpenHands Cloud" + * - For other pages: "OpenHands" or "OpenHands Cloud" + */ +export const useAppTitle = () => { + const { data: config } = useConfig(); + const { conversationId } = useParams<{ conversationId: string }>(); + const { data: conversation } = useUserConversation(conversationId ?? null); + + const appTitle = config?.APP_MODE === "oss" ? APP_TITLE_OSS : APP_TITLE_SAAS; + const conversationTitle = conversation?.title; + + if (conversationId && conversationTitle) { + return `${conversationTitle} | ${appTitle}`; + } + + return appTitle; +}; diff --git a/frontend/src/hooks/use-document-title-from-state.ts b/frontend/src/hooks/use-document-title-from-state.ts deleted file mode 100644 index 912c4eb1de..0000000000 --- a/frontend/src/hooks/use-document-title-from-state.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { useEffect, useRef } from "react"; -import { useActiveConversation } from "./query/use-active-conversation"; - -/** - * Hook that updates the document title based on the current conversation. - * This ensures that any changes to the conversation title are reflected in the document title. - * - * @param suffix Optional suffix to append to the title (default: "OpenHands") - */ -export function useDocumentTitleFromState(suffix = "OpenHands") { - const { data: conversation } = useActiveConversation(); - const lastValidTitleRef = useRef(null); - - useEffect(() => { - if (conversation?.title) { - lastValidTitleRef.current = conversation.title; - document.title = `${conversation.title} | ${suffix}`; - } else { - document.title = suffix; - } - - return () => { - document.title = suffix; - }; - }, [conversation?.title, suffix]); -} diff --git a/frontend/src/routes/conversation.tsx b/frontend/src/routes/conversation.tsx index 0237878e1e..3063c2d89c 100644 --- a/frontend/src/routes/conversation.tsx +++ b/frontend/src/routes/conversation.tsx @@ -16,7 +16,6 @@ import { useActiveConversation } from "#/hooks/query/use-active-conversation"; import { useTaskPolling } from "#/hooks/query/use-task-polling"; import { displayErrorToast } from "#/utils/custom-toast-handlers"; -import { useDocumentTitleFromState } from "#/hooks/use-document-title-from-state"; import { useIsAuthed } from "#/hooks/query/use-is-authed"; import { ConversationSubscriptionsProvider } from "#/context/conversation-subscriptions-provider"; import { useUserProviders } from "#/hooks/use-user-providers"; @@ -33,7 +32,6 @@ import { useEventStore } from "#/stores/use-event-store"; function AppContent() { useConversationConfig(); - const { t } = useTranslation(); const { conversationId } = useConversationId(); const clearEvents = useEventStore((state) => state.clearEvents); @@ -62,9 +60,6 @@ function AppContent() { // Fetch batch feedback data when conversation is loaded useBatchFeedback(); - // Set the document title to the conversation title when available - useDocumentTitleFromState(); - // 1. Cleanup Effect - runs when navigating to a different conversation React.useEffect(() => { clearTerminal(); diff --git a/frontend/src/routes/root-layout.tsx b/frontend/src/routes/root-layout.tsx index 2c5fa735d0..37ab48ebe8 100644 --- a/frontend/src/routes/root-layout.tsx +++ b/frontend/src/routes/root-layout.tsx @@ -32,6 +32,7 @@ import { LOCAL_STORAGE_KEYS } from "#/utils/local-storage"; import { EmailVerificationGuard } from "#/components/features/guards/email-verification-guard"; import { MaintenanceBanner } from "#/components/features/maintenance/maintenance-banner"; import { cn, isMobileDevice } from "#/utils/utils"; +import { useAppTitle } from "#/hooks/use-app-title"; export function ErrorBoundary() { const error = useRouteError(); @@ -67,6 +68,7 @@ export function ErrorBoundary() { } export default function MainApp() { + const appTitle = useAppTitle(); const navigate = useNavigate(); const { pathname } = useLocation(); const isOnTosPage = useIsOnTosPage(); @@ -223,6 +225,7 @@ export default function MainApp() { isMobileDevice() && "overflow-hidden", )} > + {appTitle}