mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-23 03:26:37 +00:00
refactor(frontend): drive document <title> from route handles
Move <title> ownership out of detail-page components into the app
shell. The four detail routes — templates/:templateId,
knowledges/:knowledgeId, settings/providers/:providerId,
flows/:flowId — now expose a titleComponent via react-router
handle. A new <DocumentTitle/> in RootLayout walks useMatches()
deepest-first and renders the matched title component. Each one
subscribes to its resource via Apollo with fetchPolicy:
'cache-only', so it reacts to the destination page's own fetch
without issuing a duplicate request.
Fixes the root cause behind bbf943e's sticky workaround: navigation
between sibling documents (DetailNavigation prev/next) tore down
the page-level state that PageTitle held, flashing a generic
fallback during data fetch. The shell-level <DocumentTitle/>
survives the remount, so the title resolves from cache before the
new page has finished mounting.
- Add src/components/shared/document-title.tsx with DocumentTitle
plus FlowTitle / KnowledgeTitle / ProviderTitle / TemplateTitle.
- Wire <DocumentTitle/> into RootLayout and attach
handle={{ titleComponent: ... }} on the four detail routes.
- Remove <PageTitle> calls and now-redundant <> wrappers from the
four page components.
- Drop the sticky setState-during-render hack from PageTitle —
listing pages that still use it never flickered, so the simple
passthrough is enough.
Lint + tsc clean, all 475 tests pass. Listing routes (Dashboard,
Flows, Templates, Knowledges, etc.) keep using PageTitle and can
migrate route-by-route without coordination.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
765d743b21
commit
3a67daaad8
@@ -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() {
|
||||
<FavoritesProvider>
|
||||
<TemplatesProvider>
|
||||
<ResourcesProvider>
|
||||
{/* Document <title> 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
|
||||
|
||||
@@ -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}</title>;
|
||||
|
||||
/**
|
||||
* Renders the document `<title>` 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');
|
||||
}
|
||||
@@ -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}</title>;
|
||||
return <title>{children ? `${children} — ${APP_NAME}` : APP_NAME}</title>;
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<PageTitle>{flowTitle ? `Flow #${flowId} — ${flowTitle}` : 'Flow'}</PageTitle>
|
||||
<header className="bg-background sticky top-0 z-10 flex h-12 w-full shrink-0 items-center gap-2 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-12">
|
||||
<div className="flex w-full items-center justify-between gap-2 px-4">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-2">
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<PageTitle>{isNew ? 'New knowledge' : (knowledge?.question ?? 'Knowledge')}</PageTitle>
|
||||
<KnowledgeLayout
|
||||
isNew={false}
|
||||
knowledge={knowledge}
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner variant="circle" />
|
||||
</div>
|
||||
</KnowledgeLayout>
|
||||
</>
|
||||
<KnowledgeLayout
|
||||
isNew={false}
|
||||
knowledge={knowledge}
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-center">
|
||||
<Spinner variant="circle" />
|
||||
</div>
|
||||
</KnowledgeLayout>
|
||||
);
|
||||
}
|
||||
|
||||
if (!isNew && !knowledge) {
|
||||
return (
|
||||
<>
|
||||
<PageTitle>{isNew ? 'New knowledge' : (knowledge?.question ?? 'Knowledge')}</PageTitle>
|
||||
<KnowledgeLayout
|
||||
isNew={false}
|
||||
knowledge={knowledge}
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="flex flex-col items-center gap-4 pt-6 text-center">
|
||||
<h2 className="text-xl font-semibold">Knowledge not found</h2>
|
||||
<p className="text-muted-foreground">
|
||||
The knowledge document you are looking for does not exist.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/knowledges')}>Back to Knowledges</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</KnowledgeLayout>
|
||||
</>
|
||||
<KnowledgeLayout
|
||||
isNew={false}
|
||||
knowledge={knowledge}
|
||||
>
|
||||
<div className="flex flex-1 items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
<CardContent className="flex flex-col items-center gap-4 pt-6 text-center">
|
||||
<h2 className="text-xl font-semibold">Knowledge not found</h2>
|
||||
<p className="text-muted-foreground">
|
||||
The knowledge document you are looking for does not exist.
|
||||
</p>
|
||||
<Button onClick={() => navigate('/knowledges')}>Back to Knowledges</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</KnowledgeLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle>{isNew ? 'New knowledge' : (knowledge?.question ?? 'Knowledge')}</PageTitle>
|
||||
<KnowledgeForm
|
||||
initialValues={initialValues}
|
||||
isNew={isNew}
|
||||
key={knowledgeId ?? 'new'}
|
||||
knowledge={knowledge}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
</>
|
||||
<KnowledgeForm
|
||||
initialValues={initialValues}
|
||||
isNew={isNew}
|
||||
key={knowledgeId ?? 'new'}
|
||||
knowledge={knowledge}
|
||||
onSubmit={handleSubmit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<PageTitle>{isNew ? 'New provider' : providerName || 'Provider'}</PageTitle>
|
||||
<StatusCard
|
||||
description="Please wait while we fetch provider configuration"
|
||||
icon={<Loader2 className="text-muted-foreground size-16 animate-spin" />}
|
||||
title="Loading provider data..."
|
||||
/>
|
||||
</>
|
||||
<StatusCard
|
||||
description="Please wait while we fetch provider configuration"
|
||||
icon={<Loader2 className="text-muted-foreground size-16 animate-spin" />}
|
||||
title="Loading provider data..."
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<>
|
||||
<PageTitle>{isNew ? 'New provider' : providerName || 'Provider'}</PageTitle>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>Error loading provider data</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
</>
|
||||
<Alert variant="destructive">
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>Error loading provider data</AlertTitle>
|
||||
<AlertDescription>{error.message}</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1395,7 +1388,6 @@ function SettingsProvider() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle>{isNew ? 'New provider' : providerName || 'Provider'}</PageTitle>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="flex items-center gap-2 text-lg font-semibold">
|
||||
|
||||
@@ -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 (
|
||||
<>
|
||||
<PageTitle>{isNew ? 'New template' : (templateName ?? 'Template')}</PageTitle>
|
||||
{pageHeader}
|
||||
<div className="flex min-h-[calc(100dvh-3rem)] items-center justify-center">
|
||||
<Spinner variant="circle" />
|
||||
@@ -673,7 +671,6 @@ function Template() {
|
||||
if (!isNew && !isLoadingTemplate && !templateData?.flowTemplate) {
|
||||
return (
|
||||
<>
|
||||
<PageTitle>{isNew ? 'New template' : (templateName ?? 'Template')}</PageTitle>
|
||||
{pageHeader}
|
||||
<div className="flex min-h-[calc(100dvh-3rem)] items-center justify-center p-4">
|
||||
<Card className="w-full max-w-2xl">
|
||||
@@ -690,7 +687,6 @@ function Template() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitle>{isNew ? 'New template' : (templateName ?? 'Template')}</PageTitle>
|
||||
{pageHeader}
|
||||
<div className="flex min-h-[calc(100dvh-3rem)]">
|
||||
<div className="flex min-w-0 flex-1 items-center justify-center p-4">
|
||||
|
||||
Reference in New Issue
Block a user