diff --git a/studio/frontend/src/app/routes/__root.tsx b/studio/frontend/src/app/routes/__root.tsx index 295f041129..47bff815e6 100644 --- a/studio/frontend/src/app/routes/__root.tsx +++ b/studio/frontend/src/app/routes/__root.tsx @@ -12,12 +12,21 @@ import { Outlet, createRootRoute, redirect, + useMatches, useRouterState, } from "@tanstack/react-router"; import { AnimatePresence, motion } from "motion/react"; -import { Suspense, useEffect, type ReactNode } from "react"; +import { Suspense, useEffect, useLayoutEffect, type ReactNode } from "react"; import { AppProvider } from "../provider"; +// Type `staticData.title` on every route so the matched-title selector +// below stays type-safe without an inline cast. +declare module "@tanstack/react-router" { + interface StaticDataRouteOption { + title?: string; + } +} + // Fallback while a lazy route bundle (Train/Recipes/Export) loads. // /chat is synchronous and never hits this. const RouteFallback: ReactNode = ( @@ -55,6 +64,9 @@ export const Route = createRootRoute({ const HIDDEN_NAVBAR_ROUTES = ["/onboarding", "/login", "/change-password"]; +// Fallback when no matched route declares a `staticData.title`. +const DEFAULT_DOCUMENT_TITLE = "Unsloth Studio"; + function RootLayout() { const pathname = useRouterState({ select: (s) => s.location.pathname }); const hideNavbar = HIDDEN_NAVBAR_ROUTES.includes(pathname); @@ -63,6 +75,30 @@ function RootLayout() { useTrainingUnloadGuard(); + // Walk matches deepest-first; each route declares its own title. + const matchedTitle = useMatches({ + select: (matches) => { + for (let i = matches.length - 1; i >= 0; i--) { + const title = matches[i].staticData.title; + if (title) return title; + } + return null; + }, + }); + + // `/settings` redirects in `beforeLoad`, so its route never stays + // matched; surface the modal's title via the store instead. + const settingsDialogOpen = useSettingsDialogStore((s) => s.open); + const documentTitle = settingsDialogOpen ? "Settings" : matchedTitle; + + // useLayoutEffect updates the tab title before paint, avoiding a + // one-frame flash of the previous route's title on navigation. + useLayoutEffect(() => { + document.title = documentTitle + ? `${documentTitle} - ${DEFAULT_DOCUMENT_TITLE}` + : DEFAULT_DOCUMENT_TITLE; + }, [documentTitle]); + useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.defaultPrevented) return; diff --git a/studio/frontend/src/app/routes/change-password.tsx b/studio/frontend/src/app/routes/change-password.tsx index 61b5194160..55c8ceaa9c 100644 --- a/studio/frontend/src/app/routes/change-password.tsx +++ b/studio/frontend/src/app/routes/change-password.tsx @@ -15,6 +15,7 @@ const ChangePasswordPage = lazy(() => export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/change-password", + staticData: { title: "Change Password" }, beforeLoad: () => requirePasswordChangeFlow(), component: ChangePasswordPage, }); diff --git a/studio/frontend/src/app/routes/chat.tsx b/studio/frontend/src/app/routes/chat.tsx index 49c05ce219..98c73aa7e0 100644 --- a/studio/frontend/src/app/routes/chat.tsx +++ b/studio/frontend/src/app/routes/chat.tsx @@ -15,6 +15,7 @@ export type ChatSearch = { export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/chat", + staticData: { title: "Chat" }, beforeLoad: () => requireAuth(), validateSearch: (search: Record): ChatSearch => ({ thread: typeof search.thread === "string" ? search.thread : undefined, diff --git a/studio/frontend/src/app/routes/data-recipes.$recipeId.tsx b/studio/frontend/src/app/routes/data-recipes.$recipeId.tsx index 998633b5e7..ae0128f800 100644 --- a/studio/frontend/src/app/routes/data-recipes.$recipeId.tsx +++ b/studio/frontend/src/app/routes/data-recipes.$recipeId.tsx @@ -16,6 +16,7 @@ const EditRecipePage = lazy(() => export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/data-recipes/$recipeId", + staticData: { title: "Data Recipes" }, beforeLoad: () => requireAuth(), component: DataRecipeEditorRoute, }); diff --git a/studio/frontend/src/app/routes/data-recipes.tsx b/studio/frontend/src/app/routes/data-recipes.tsx index bafdc43921..c35e63da5f 100644 --- a/studio/frontend/src/app/routes/data-recipes.tsx +++ b/studio/frontend/src/app/routes/data-recipes.tsx @@ -15,6 +15,7 @@ const DataRecipesPage = lazy(() => export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/data-recipes", + staticData: { title: "Data Recipes" }, beforeLoad: () => requireAuth(), component: DataRecipesPage, }); diff --git a/studio/frontend/src/app/routes/export.tsx b/studio/frontend/src/app/routes/export.tsx index 4bb311e56d..c0356c823f 100644 --- a/studio/frontend/src/app/routes/export.tsx +++ b/studio/frontend/src/app/routes/export.tsx @@ -15,6 +15,7 @@ const ExportPage = lazy(() => export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/export", + staticData: { title: "Export" }, beforeLoad: () => requireAuth(), component: ExportPage, }); diff --git a/studio/frontend/src/app/routes/login.tsx b/studio/frontend/src/app/routes/login.tsx index 409ba53375..bfd1b82132 100644 --- a/studio/frontend/src/app/routes/login.tsx +++ b/studio/frontend/src/app/routes/login.tsx @@ -13,6 +13,7 @@ const LoginPage = lazy(() => export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/login", + staticData: { title: "Login" }, beforeLoad: () => requireGuest(), component: LoginPage, }); diff --git a/studio/frontend/src/app/routes/onboarding.tsx b/studio/frontend/src/app/routes/onboarding.tsx index 8d1cd6ff5f..6c31d794ba 100644 --- a/studio/frontend/src/app/routes/onboarding.tsx +++ b/studio/frontend/src/app/routes/onboarding.tsx @@ -17,6 +17,7 @@ const WizardLayout = lazy(() => export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/onboarding", + staticData: { title: "Onboarding" }, beforeLoad: () => requireAuth(), validateSearch: (search: Record): OnboardingSearch => ({ redirectTo: typeof search.redirectTo === "string" ? search.redirectTo : undefined, diff --git a/studio/frontend/src/app/routes/settings.tsx b/studio/frontend/src/app/routes/settings.tsx index 4e35f0b16d..fa97a450f7 100644 --- a/studio/frontend/src/app/routes/settings.tsx +++ b/studio/frontend/src/app/routes/settings.tsx @@ -8,9 +8,13 @@ import { requireAuth } from "../auth-guards"; import { Route as rootRoute } from "./__root"; // /settings is a deep link to the modal. Open it, then redirect home. +// Tab title is driven by useSettingsDialogStore in __root.tsx since the +// redirect means /settings never stays matched; staticData is just a +// safety net if beforeLoad ever stops throwing. export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/settings", + staticData: { title: "Settings" }, beforeLoad: async () => { await requireAuth(); useSettingsDialogStore.getState().openDialog(); diff --git a/studio/frontend/src/app/routes/studio.tsx b/studio/frontend/src/app/routes/studio.tsx index bfdabe882f..75f1a1b937 100644 --- a/studio/frontend/src/app/routes/studio.tsx +++ b/studio/frontend/src/app/routes/studio.tsx @@ -15,6 +15,7 @@ const StudioPage = lazy(() => export const Route = createRoute({ getParentRoute: () => rootRoute, path: "/studio", + staticData: { title: "Train" }, beforeLoad: () => requireAuth(), component: StudioPage, });