diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 6de09926..6ba78225 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -15,6 +15,13 @@ import MainLayout from '@/components/layouts/main-layout'; import SettingsLayout from '@/components/layouts/settings-layout'; import ProtectedRoute from '@/components/routes/protected-route'; import PublicRoute from '@/components/routes/public-route'; +import { + DocumentTitle, + FlowTitle, + KnowledgeTitle, + ProviderTitle, + TemplateTitle, +} from '@/components/shared/document-title'; import PageLoader from '@/components/shared/page-loader'; import { Toaster } from '@/components/ui/sonner'; import client from '@/lib/apollo'; @@ -107,6 +114,11 @@ function RootLayout() { + {/* Document driven by route handles — lives in the + shell so it survives navigation between sibling detail + routes (templates/:id → templates/:id') without + flashing a generic fallback during data fetch. */} + <DocumentTitle /> <Suspense fallback={<PageLoader />}> <Outlet /> </Suspense> @@ -141,6 +153,7 @@ const router = createBrowserRouter( /> <Route element={<FlowWithProvider />} + handle={{ titleComponent: FlowTitle }} path="flows/:flowId" /> </Route> @@ -151,6 +164,7 @@ const router = createBrowserRouter( /> <Route element={<Template />} + handle={{ titleComponent: TemplateTitle }} path="templates/:templateId" /> @@ -161,6 +175,7 @@ const router = createBrowserRouter( /> <Route element={<Knowledge />} + handle={{ titleComponent: KnowledgeTitle }} path="knowledges/:knowledgeId" /> </Route> @@ -191,6 +206,7 @@ const router = createBrowserRouter( /> <Route element={<SettingsProvider />} + handle={{ titleComponent: ProviderTitle }} path="providers/:providerId" /> <Route diff --git a/frontend/src/components/shared/document-title.tsx b/frontend/src/components/shared/document-title.tsx new file mode 100644 index 00000000..e9996726 --- /dev/null +++ b/frontend/src/components/shared/document-title.tsx @@ -0,0 +1,125 @@ +import type { ComponentType } from 'react'; + +import { useMatches } from 'react-router-dom'; + +import { + useFlowQuery, + useFlowTemplateQuery, + useKnowledgeDocumentQuery, + useSettingsProvidersQuery, +} from '@/graphql/types'; + +const APP_NAME = 'PentAGI'; + +export interface RouteHandleWithTitle { + titleComponent: TitleComponent; +} + +type RouteParams = Record<string, string | undefined>; + +type TitleComponent = ComponentType<{ params: RouteParams }>; + +const hasTitleComponent = (handle: unknown): handle is RouteHandleWithTitle => + typeof handle === 'object' && + handle !== null && + 'titleComponent' in handle && + typeof (handle as RouteHandleWithTitle).titleComponent === 'function'; + +const renderTitle = (label: null | string) => <title>{label ? `${label} — ${APP_NAME}` : APP_NAME}; + +/** + * Renders the document `` driven by react-router route handles. Walks + * matches deepest-first; the first match exposing `handle.titleComponent` + * wins. Lives in the app shell so it survives navigation between sibling + * detail routes — that's what fixes the previous "Provider — PentAGI" + * flash when DetailNavigation switches between siblings and the destination + * page unmounts/remounts during data fetch. + * + * Routes without a `titleComponent` keep using `<PageTitle>` inside the page + * component — the migration is route-by-route. + */ +export function DocumentTitle() { + const matches = useMatches(); + + for (let i = matches.length - 1; i >= 0; i--) { + const handle = matches[i].handle; + + if (hasTitleComponent(handle)) { + const TitleComponent = handle.titleComponent; + + return <TitleComponent params={matches[i].params} />; + } + } + + return null; +} + +// Per-resource title components read the same Apollo cache the destination +// page is about to populate. `fetchPolicy: 'cache-only'` avoids a duplicate +// HTTP request — the page's own query fills the cache, this subscription +// reacts to it. The variables shape mirrors the page so cache lookups hit. + +export function FlowTitle({ params }: { params: RouteParams }) { + const flowId = params.flowId; + const { data } = useFlowQuery({ + fetchPolicy: 'cache-only', + skip: !flowId, + variables: flowId ? { id: flowId } : undefined, + }); + + const flowTitle = data?.flow?.title; + + return renderTitle(flowTitle && flowId ? `Flow #${flowId} — ${flowTitle}` : 'Flow'); +} + +export function KnowledgeTitle({ params }: { params: RouteParams }) { + const knowledgeId = params.knowledgeId; + const isNew = knowledgeId === 'new'; + const { data } = useKnowledgeDocumentQuery({ + fetchPolicy: 'cache-only', + skip: isNew || !knowledgeId, + variables: !isNew && knowledgeId ? { id: knowledgeId } : undefined, + }); + + if (isNew) { + return renderTitle('New knowledge'); + } + + return renderTitle(data?.knowledgeDocument?.question || 'Knowledge'); +} + +export function ProviderTitle({ params }: { params: RouteParams }) { + const providerId = params.providerId; + const isNew = providerId === 'new'; + const { data } = useSettingsProvidersQuery({ + fetchPolicy: 'cache-only', + skip: isNew, + }); + + if (isNew) { + return renderTitle('New provider'); + } + + // Provider detail isn't a separate query — `settingsProviders` returns the + // full list and the page filters by id. We do the same here so a single + // cache entry serves both. + const provider = data?.settingsProviders.userDefined?.find((candidate) => candidate.id == providerId); + + return renderTitle(provider?.name || 'Provider'); +} + +export function TemplateTitle({ params }: { params: RouteParams }) { + const templateId = params.templateId; + const isNew = templateId === 'new'; + const { data } = useFlowTemplateQuery({ + fetchPolicy: 'cache-only', + skip: isNew || !templateId, + variables: templateId && !isNew ? { templateId } : undefined, + }); + + if (isNew) { + return renderTitle('New template'); + } + + return renderTitle(data?.flowTemplate?.title || 'Template'); +} diff --git a/frontend/src/components/shared/page-title.tsx b/frontend/src/components/shared/page-title.tsx index d5bac485..4c41ca82 100644 --- a/frontend/src/components/shared/page-title.tsx +++ b/frontend/src/components/shared/page-title.tsx @@ -1,5 +1,3 @@ -import { useState } from 'react'; - const APP_NAME = 'PentAGI'; interface PageTitleProps { @@ -7,33 +5,19 @@ interface PageTitleProps { * Page-specific prefix. Skipped when nullish/empty so loading states can * render `<PageTitle>{flow?.title}</PageTitle>` without temporarily * setting the tab title to "— PentAGI". - * - * While this component is mounted (i.e. the user stays on the same route), - * the last non-empty value is "sticky" — a brief loading state (e.g. when - * DetailNavigation switches between siblings and the next document is in - * flight) keeps showing the previous title rather than flashing the - * generic "Provider/Knowledge/Template — PentAGI" fallback. State is - * tied to the component instance so it resets cleanly on route change. */ children?: null | string; } /** * Renders a `<title>` element that React 19 hoists into <head> automatically. - * Each page-level route component drops one of these at the top of its JSX so - * the browser tab, history, and shareable links reflect the actual page — - * instead of the static "PentAGI" coming from index.html. + * + * Detail routes (templates/:id, knowledges/:id, providers/:id, flows/:id) + * use route handles via `<DocumentTitle/>` instead — that lets the shell + * own the title and survives navigation between sibling documents. This + * helper is for listing/static pages where in-route flicker isn't a + * concern. */ export function PageTitle({ children }: PageTitleProps) { - const [sticky, setSticky] = useState<null | string>(children ?? null); - - // React 19 supports setState during render for derived state; it triggers - // a synchronous re-render before commit and avoids a one-frame flicker. - if (children && children !== sticky) { - setSticky(children); - } - - const effective = children || sticky; - - return <title>{effective ? `${effective} — ${APP_NAME}` : APP_NAME}; + return {children ? `${children} — ${APP_NAME}` : APP_NAME}; } diff --git a/frontend/src/pages/flows/flow.tsx b/frontend/src/pages/flows/flow.tsx index d3ead6ec..1d6159d9 100644 --- a/frontend/src/pages/flows/flow.tsx +++ b/frontend/src/pages/flows/flow.tsx @@ -29,7 +29,6 @@ import { } from '@/components/shared/detail-navigation'; import { HeaderButton } from '@/components/shared/header-button'; import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit'; -import { PageTitle } from '@/components/shared/page-title'; import { Badge } from '@/components/ui/badge'; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; import { Button } from '@/components/ui/button'; @@ -204,7 +203,6 @@ function Flow() { return ( <> - {flowTitle ? `Flow #${flowId} — ${flowTitle}` : 'Flow'}
diff --git a/frontend/src/pages/knowledges/knowledge.tsx b/frontend/src/pages/knowledges/knowledge.tsx index d391d426..95a1282a 100644 --- a/frontend/src/pages/knowledges/knowledge.tsx +++ b/frontend/src/pages/knowledges/knowledge.tsx @@ -1,7 +1,6 @@ import { useCallback, useMemo } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; -import { PageTitle } from '@/components/shared/page-title'; import { Button } from '@/components/ui/button'; import { Card, CardContent } from '@/components/ui/card'; import { Spinner } from '@/components/ui/spinner'; @@ -68,55 +67,46 @@ function Knowledge() { if (!isNew && isLoadingKnowledge) { return ( - <> - {isNew ? 'New knowledge' : (knowledge?.question ?? 'Knowledge')} - -
- -
-
- + +
+ +
+
); } if (!isNew && !knowledge) { return ( - <> - {isNew ? 'New knowledge' : (knowledge?.question ?? 'Knowledge')} - -
- - -

Knowledge not found

-

- The knowledge document you are looking for does not exist. -

- -
-
-
-
- + +
+ + +

Knowledge not found

+

+ The knowledge document you are looking for does not exist. +

+ +
+
+
+
); } return ( - <> - {isNew ? 'New knowledge' : (knowledge?.question ?? 'Knowledge')} - - + ); } diff --git a/frontend/src/pages/settings/settings-provider.tsx b/frontend/src/pages/settings/settings-provider.tsx index 3e5a74e6..fd385c72 100644 --- a/frontend/src/pages/settings/settings-provider.tsx +++ b/frontend/src/pages/settings/settings-provider.tsx @@ -26,7 +26,6 @@ import type { } from '@/graphql/types'; import ConfirmationDialog from '@/components/shared/confirmation-dialog'; -import { PageTitle } from '@/components/shared/page-title'; import { Accordion, AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'; import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'; import { Button } from '@/components/ui/button'; @@ -1363,27 +1362,21 @@ function SettingsProvider() { if (loading) { return ( - <> - {isNew ? 'New provider' : providerName || 'Provider'} - } - title="Loading provider data..." - /> - + } + title="Loading provider data..." + /> ); } if (error) { return ( - <> - {isNew ? 'New provider' : providerName || 'Provider'} - - - Error loading provider data - {error.message} - - + + + Error loading provider data + {error.message} + ); } @@ -1395,7 +1388,6 @@ function SettingsProvider() { return ( <> - {isNew ? 'New provider' : providerName || 'Provider'}

diff --git a/frontend/src/pages/templates/template.tsx b/frontend/src/pages/templates/template.tsx index 6f45f0ec..0262c273 100644 --- a/frontend/src/pages/templates/template.tsx +++ b/frontend/src/pages/templates/template.tsx @@ -26,7 +26,6 @@ import { DetailNavigationToolbar, } from '@/components/shared/detail-navigation'; import { InlineEditInput, useInlineEdit } from '@/components/shared/inline-edit'; -import { PageTitle } from '@/components/shared/page-title'; import { Badge } from '@/components/ui/badge'; import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb'; import { Button } from '@/components/ui/button'; @@ -660,7 +659,6 @@ function Template() { if (!isNew && isLoadingTemplate) { return ( <> - {isNew ? 'New template' : (templateName ?? 'Template')} {pageHeader}
@@ -673,7 +671,6 @@ function Template() { if (!isNew && !isLoadingTemplate && !templateData?.flowTemplate) { return ( <> - {isNew ? 'New template' : (templateName ?? 'Template')} {pageHeader}
@@ -690,7 +687,6 @@ function Template() { return ( <> - {isNew ? 'New template' : (templateName ?? 'Template')} {pageHeader}