mirror of
https://github.com/All-Hands-AI/OpenHands.git
synced 2026-01-09 23:08:04 -05:00
feat(frontend): create useAppTitle hook for dynamic document titles (#12224)
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
105
frontend/src/hooks/use-app-title.test.tsx
Normal file
105
frontend/src/hooks/use-app-title.test.tsx
Normal 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"));
|
||||
});
|
||||
});
|
||||
26
frontend/src/hooks/use-app-title.ts
Normal file
26
frontend/src/hooks/use-app-title.ts
Normal 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;
|
||||
};
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user