From 7eca3cdfcaefa91775e3f3ffc4e335c9f4079429 Mon Sep 17 00:00:00 2001 From: RockChinQ Date: Sat, 6 Jun 2026 12:12:08 -0400 Subject: [PATCH] feat(web): show sub-entity name in document title on detail pages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Detail pages (plugin / MCP / pipeline / knowledge base / skill) only showed the type in the tab title. Drive the /home document title from HomeLayout, which has the selected entity name via context: ' · · LangBot' when a sub-entity is open, ' · LangBot' otherwise. The top-level hook now skips /home and only handles login/register/reset-password/wizard. Type label falls back to a route-derived i18n key on direct page loads. --- web/src/app/home/layout.tsx | 37 +++++++++++++++++ web/src/hooks/useDocumentTitle.ts | 68 +++++++++++++++++-------------- 2 files changed, 74 insertions(+), 31 deletions(-) diff --git a/web/src/app/home/layout.tsx b/web/src/app/home/layout.tsx index d48f7407..1f68241d 100644 --- a/web/src/app/home/layout.tsx +++ b/web/src/app/home/layout.tsx @@ -42,6 +42,7 @@ import { PluginInstallTaskProvider, PluginInstallProgressDialog, } from '@/app/home/plugins/components/plugin-install-task'; +import { setDocumentTitle } from '@/hooks/useDocumentTitle'; // Routes that belong to the "Extensions" section const EXTENSIONS_ROUTES = [ @@ -52,6 +53,28 @@ const EXTENSIONS_ROUTES = [ '/home/plugin-pages', ]; +// Map a /home route to the i18n key for its type-level title. Used as a robust +// fallback for the document title on direct page loads, before the sidebar's +// onSelectedChange has populated the local `title` state. Detail routes reuse +// the section key (prefix match), e.g. /home/mcp?id=... -> mcp.title. +const HOME_TITLE_KEYS: { match: (path: string) => boolean; key: string }[] = [ + { match: (p) => p.startsWith('/home/monitoring'), key: 'monitoring.title' }, + { match: (p) => p.startsWith('/home/bots'), key: 'bots.title' }, + { match: (p) => p.startsWith('/home/pipelines'), key: 'pipelines.title' }, + { + match: (p) => p.startsWith('/home/add-extension'), + key: 'sidebar.addExtension', + }, + { match: (p) => p.startsWith('/home/extensions'), key: 'plugins.title' }, + { match: (p) => p.startsWith('/home/mcp'), key: 'mcp.title' }, + { match: (p) => p.startsWith('/home/knowledge'), key: 'knowledge.title' }, + { match: (p) => p.startsWith('/home/skills'), key: 'skills.title' }, + { + match: (p) => p.startsWith('/home/plugin-pages'), + key: 'sidebar.pluginPages', + }, +]; + function isExtensionsRoute(pathname: string): boolean { return EXTENSIONS_ROUTES.some( (route) => pathname === route || pathname.startsWith(route + '/'), @@ -147,6 +170,20 @@ function HomeLayoutInner({ children }: { children: React.ReactNode }) { : t('sidebar.home'); const sectionLink = isExtensions ? '/home/extensions' : '/home/monitoring'; + // Drive the browser tab title for the /home section. The type-level label + // prefers the sidebar-provided `title`, falling back to a route-derived key on + // direct page loads. When a sub-entity (plugin / MCP / pipeline / KB / skill) + // is open, its name is prepended: " · · LangBot". + useEffect(() => { + const routeEntry = HOME_TITLE_KEYS.find((e) => e.match(pathname)); + const fallbackType = + routeEntry && t(routeEntry.key) !== routeEntry.key + ? t(routeEntry.key) + : null; + const typeLabel = title || fallbackType; + setDocumentTitle(detailEntityName, typeLabel); + }, [pathname, title, detailEntityName, t]); + return ( }> diff --git a/web/src/hooks/useDocumentTitle.ts b/web/src/hooks/useDocumentTitle.ts index bc54e320..449e47f7 100644 --- a/web/src/hooks/useDocumentTitle.ts +++ b/web/src/hooks/useDocumentTitle.ts @@ -4,56 +4,62 @@ import { useTranslation } from 'react-i18next'; const APP_NAME = 'LangBot'; -// Map a route path to the i18n key used for its document title. Detail routes -// reuse the section key (e.g. /home/bots and any future /home/bots/:id both -// resolve to bots.title). Reuses existing translation keys so titles stay in -// sync with the sidebar and page headers across all locales. +// Map a route path to the i18n key used for its (type-level) document title. +// Reuses existing translation keys so titles stay in sync with the sidebar and +// page headers across all locales. The /home/* section is intentionally NOT +// listed here: those titles are driven from inside HomeLayout, which has access +// to the currently-selected sub-entity name (detailEntityName) via context and +// renders " · · LangBot". const ROUTE_TITLE_KEYS: { match: (path: string) => boolean; key: string }[] = [ { match: (p) => p === '/login', key: 'common.login' }, { match: (p) => p === '/register', key: 'register.title' }, { match: (p) => p === '/reset-password', key: 'resetPassword.title' }, { match: (p) => p === '/wizard', key: 'sidebar.quickStart' }, - { match: (p) => p.startsWith('/home/monitoring'), key: 'monitoring.title' }, - { match: (p) => p.startsWith('/home/bots'), key: 'bots.title' }, - { match: (p) => p.startsWith('/home/pipelines'), key: 'pipelines.title' }, - { - match: (p) => p.startsWith('/home/add-extension'), - key: 'sidebar.addExtension', - }, - { match: (p) => p.startsWith('/home/extensions'), key: 'plugins.title' }, - { match: (p) => p.startsWith('/home/mcp'), key: 'mcp.title' }, - { match: (p) => p.startsWith('/home/knowledge'), key: 'knowledge.title' }, - { match: (p) => p.startsWith('/home/skills'), key: 'skills.title' }, - { - match: (p) => p.startsWith('/home/plugin-pages'), - key: 'sidebar.pluginPages', - }, - // /home (and anything else under it) falls back to the dashboard. - { match: (p) => p.startsWith('/home'), key: 'monitoring.title' }, ]; /** - * Keeps document.title in sync with the current route, formatted as - * " · LangBot". On routes with no specific mapping (or before i18n is - * ready) it falls back to the bare app name. Re-runs on navigation and on - * language change so the title is always localized. + * Builds a "<...parts> · LangBot" document title from the given page-name parts, + * dropping empties. Falls back to the bare app name when no parts resolve. + */ +export function buildDocumentTitle( + ...parts: (string | null | undefined)[] +): string { + const clean = parts.filter((p): p is string => !!p && p.trim().length > 0); + return clean.length > 0 ? `${clean.join(' · ')} · ${APP_NAME}` : APP_NAME; +} + +/** + * Imperatively set the document title. Centralized so the format stays + * consistent across the top-level layout and the home layout. + */ +export function setDocumentTitle( + ...parts: (string | null | undefined)[] +): void { + document.title = buildDocumentTitle(...parts); +} + +/** + * Top-level document-title driver for routes OUTSIDE the /home section + * (login, register, reset-password, wizard, and any unmapped route). The /home + * section manages its own title from HomeLayout so it can include the selected + * sub-entity name. Re-runs on navigation and language change so the title stays + * localized. */ export function useDocumentTitle() { const { pathname } = useLocation(); const { t, i18n } = useTranslation(); useEffect(() => { + // Home routes are handled by HomeLayout (it has the entity name in context). + if (pathname.startsWith('/home')) return; + const entry = ROUTE_TITLE_KEYS.find((e) => e.match(pathname)); if (!entry) { document.title = APP_NAME; return; } const pageName = t(entry.key); - // Guard against an unresolved key (returns the key itself) leaking into the - // title; fall back to the bare app name in that case. - document.title = - pageName && pageName !== entry.key - ? `${pageName} · ${APP_NAME}` - : APP_NAME; + // Guard against an unresolved key (t returns the key itself). + setDocumentTitle(pageName !== entry.key ? pageName : null); }, [pathname, t, i18n.language]); }