feat(frontend): create useAppTitle hook for dynamic document titles (#12224)

This commit is contained in:
sp.wack
2026-01-05 23:17:53 +04:00
committed by GitHub
parent 5744f6602b
commit 9b834bf660
6 changed files with 134 additions and 166 deletions

View File

@@ -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");
});
});

View File

@@ -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 }) => (
<QueryClientProvider client={new QueryClient()}>
{children}
</QueryClientProvider>
),
});
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"));
});
});

View File

@@ -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;
};

View File

@@ -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<string | null>(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]);
}

View File

@@ -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();

View File

@@ -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",
)}
>
<title>{appTitle}</title>
<Sidebar />
<div className="flex flex-col w-full h-[calc(100%-50px)] md:h-full gap-3">