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}