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 <michaelhan2050@gmail.com>
This commit is contained in:
Daniel Han 2026-05-22 05:02:24 -07:00 committed by GitHub
commit ac8973c493
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 49 additions and 1 deletions

View file

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

View file

@ -15,6 +15,7 @@ const ChangePasswordPage = lazy(() =>
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/change-password",
staticData: { title: "Change Password" },
beforeLoad: () => requirePasswordChangeFlow(),
component: ChangePasswordPage,
});

View file

@ -15,6 +15,7 @@ export type ChatSearch = {
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/chat",
staticData: { title: "Chat" },
beforeLoad: () => requireAuth(),
validateSearch: (search: Record<string, unknown>): ChatSearch => ({
thread: typeof search.thread === "string" ? search.thread : undefined,

View file

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

View file

@ -15,6 +15,7 @@ const DataRecipesPage = lazy(() =>
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/data-recipes",
staticData: { title: "Data Recipes" },
beforeLoad: () => requireAuth(),
component: DataRecipesPage,
});

View file

@ -15,6 +15,7 @@ const ExportPage = lazy(() =>
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/export",
staticData: { title: "Export" },
beforeLoad: () => requireAuth(),
component: ExportPage,
});

View file

@ -13,6 +13,7 @@ const LoginPage = lazy(() =>
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/login",
staticData: { title: "Login" },
beforeLoad: () => requireGuest(),
component: LoginPage,
});

View file

@ -17,6 +17,7 @@ const WizardLayout = lazy(() =>
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/onboarding",
staticData: { title: "Onboarding" },
beforeLoad: () => requireAuth(),
validateSearch: (search: Record<string, unknown>): OnboardingSearch => ({
redirectTo: typeof search.redirectTo === "string" ? search.redirectTo : undefined,

View file

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

View file

@ -15,6 +15,7 @@ const StudioPage = lazy(() =>
export const Route = createRoute({
getParentRoute: () => rootRoute,
path: "/studio",
staticData: { title: "Train" },
beforeLoad: () => requireAuth(),
component: StudioPage,
});