From ac8973c493049346eab216ac3bf1b7aff1cd7ea3 Mon Sep 17 00:00:00 2001 From: Daniel Han Date: Fri, 22 May 2026 05:02:24 -0700 Subject: [PATCH] studio/frontend: set per-route document.title (#5660) * studio/frontend: set per-route document.title The browser tab title was hardcoded to "Unsloth Studio" in index.html and never updated. Users running multiple Studio installs (or browsing several threads in separate tabs) saw the same tab label everywhere, making the OS / browser tab strip useless for switching between them. Map known route prefixes (Chat, Train, Data Recipes, Export, Settings, Login, Onboarding, Change Password) to a "Label - Unsloth Studio" tab title and update document.title from a small effect inside RootLayout. Unknown routes keep the original "Unsloth Studio". Resolves #5659. * studio/frontend: per-route document.title via staticData + useMatches Address review feedback on #5660 (gemini-code-assist): move titles from the centralized ROUTE_TITLES map in __root.tsx into each route's `staticData: { title }` and read the deepest matched route's title via `useMatches`. This co-locates the title with the route definition, so renames or new routes only have to touch one file, and drops the pathname.startsWith(...) string matching. Routes given a title (everything that actually renders chrome): - /chat -> "Chat" - /studio -> "Train" - /data-recipes -> "Data Recipes" - /data-recipes/$recipeId -> "Data Recipes" - /export -> "Export" - /login -> "Login" - /onboarding -> "Onboarding" - /change-password -> "Change Password" /settings and / both redirect on `beforeLoad`, so they never render and don't need a title; they fall through to the default "Unsloth Studio". The previous PR's ROUTE_TITLES + routeTitle() helper are removed from __root.tsx. tsc + vite build clean; bundle confirms every route carries its `staticData:{title:...}` and __root.tsx's useMatches selector walks matches deepest-first. * studio/frontend: type staticData.title via module augmentation + useLayoutEffect - Augment `StaticDataRouteOption` so `createRoute({ staticData: { title } })` is typed at the leaves and the layout reads `match.staticData.title` without the inline cast. - Switch the title-writing effect to `useLayoutEffect` so the tab title updates synchronously and doesn't flash the previous route's title for a frame during in-app navigation. - Use " | " separator (web convention) for the document title. * studio/frontend: Settings dialog drives document.title + revert separator to PR contract 12/12 reviewers flagged that /settings is a modal deep link whose route throws redirect in beforeLoad, so useMatches resolves to the post-auth route (usually /chat). The tab title therefore showed "Chat - Unsloth Studio" while the user was actually looking at the Settings dialog. Fix: - Subscribe to useSettingsDialogStore.open in __root.tsx and prefer "Settings" as the document title while the dialog is visible. - Add staticData.title = "Settings" on /settings for the rare case beforeLoad returns without throwing (future refactor); the live source-of-truth is the dialog store since the redirect means the route never matches. Also revert the document title separator from " | " back to " - " to match the PR description / acceptance contract that the previous round inadvertently broke. * studio/frontend: tighten document-title comments --------- Co-authored-by: danielhanchen --- studio/frontend/src/app/routes/__root.tsx | 38 ++++++++++++++++++- .../src/app/routes/change-password.tsx | 1 + studio/frontend/src/app/routes/chat.tsx | 1 + .../src/app/routes/data-recipes.$recipeId.tsx | 1 + .../frontend/src/app/routes/data-recipes.tsx | 1 + studio/frontend/src/app/routes/export.tsx | 1 + studio/frontend/src/app/routes/login.tsx | 1 + studio/frontend/src/app/routes/onboarding.tsx | 1 + studio/frontend/src/app/routes/settings.tsx | 4 ++ studio/frontend/src/app/routes/studio.tsx | 1 + 10 files changed, 49 insertions(+), 1 deletion(-) 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, });