refactor(frontend): collapse PageTitle into DocumentTitle via route handles

Single mechanism for the document <title>. Every route now declares its
title in the react-router handle:

  • handle: { title: 'Dashboard' } — static listing pages
  • handle: { title: (params) => formatPromptId(params.promptId) } —
    params-derived (e.g. /settings/prompts/:promptId)
  • handle: { titleComponent: TemplateTitle } — Apollo cache-driven,
    reactive to cache updates after the page mounts

A single <DocumentTitle/> in RootLayout walks useMatches() and picks
the deepest match. PageTitle is gone — 13 call sites across 13 page
components migrated to handle, the component deleted.

Other improvements:
- Split document-title.tsx so the shell-only <DocumentTitle/> has zero
  graphql imports — the resource title components (FlowTitle,
  KnowledgeTitle, ProviderTitle, TemplateTitle) live in
  resource-titles.tsx. This lets the unit tests render DocumentTitle
  without dragging the codegenerated graphql/types.ts through Vite.
- String(p.id) === providerId replaces sloppy == in ProviderTitle.
- matches.findLast(...) replaces the imperative for-loop.
- Extracted formatPromptId into lib/utils/format-prompt-id.ts so the
  prompt detail page and the route handle resolver share one formatter.

7 unit tests added for DocumentTitle covering: static title, derived
title from params, deepest-match-wins, titleComponent rendering,
titleComponent precedence over static title, empty-string fallback,
and no-handle fallback.

19 test files / 482 tests pass; lint + tsc clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-05-19 09:39:25 +07:00
co-authored by Claude Opus 4.7
parent 3a67daaad8
commit f2fb222807
20 changed files with 330 additions and 173 deletions
+19 -7
View File
@@ -15,16 +15,12 @@ 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 { DocumentTitle } from '@/components/shared/document-title';
import PageLoader from '@/components/shared/page-loader';
import { FlowTitle, KnowledgeTitle, ProviderTitle, TemplateTitle } from '@/components/shared/resource-titles';
import { Toaster } from '@/components/ui/sonner';
import client from '@/lib/apollo';
import { formatPromptId } from '@/lib/utils/format-prompt-id';
import { FavoritesProvider } from '@/providers/favorites-provider';
import { FlowProvider } from '@/providers/flow-provider';
import { KnowledgesProvider } from '@/providers/knowledges-provider';
@@ -138,6 +134,7 @@ const router = createBrowserRouter(
<Route element={<MainLayout />}>
<Route
element={<Dashboard />}
handle={{ title: 'Dashboard' }}
path="dashboard"
/>
@@ -145,10 +142,12 @@ const router = createBrowserRouter(
<Route element={<FlowsLayout />}>
<Route
element={<Flows />}
handle={{ title: 'Flows' }}
path="flows"
/>
<Route
element={<NewFlow />}
handle={{ title: 'New flow' }}
path="flows/new"
/>
<Route
@@ -160,6 +159,7 @@ const router = createBrowserRouter(
<Route
element={<Templates />}
handle={{ title: 'Templates' }}
path="templates"
/>
<Route
@@ -171,6 +171,7 @@ const router = createBrowserRouter(
<Route element={<KnowledgesLayout />}>
<Route
element={<Knowledges />}
handle={{ title: 'Knowledges' }}
path="knowledges"
/>
<Route
@@ -182,6 +183,7 @@ const router = createBrowserRouter(
<Route
element={<Resources />}
handle={{ title: 'Resources' }}
path="resources"
/>
</Route>
@@ -202,6 +204,7 @@ const router = createBrowserRouter(
/>
<Route
element={<SettingsProviders />}
handle={{ title: 'Providers' }}
path="providers"
/>
<Route
@@ -211,14 +214,20 @@ const router = createBrowserRouter(
/>
<Route
element={<SettingsPrompts />}
handle={{ title: 'Prompts' }}
path="prompts"
/>
<Route
element={<SettingsPrompt />}
handle={{
title: (params: Record<string, string | undefined>) =>
params.promptId ? formatPromptId(params.promptId) : 'Prompt',
}}
path="prompts/:promptId"
/>
<Route
element={<SettingsAPITokens />}
handle={{ title: 'API tokens' }}
path="api-tokens"
/>
{/* Catch-all route for unknown settings paths */}
@@ -237,17 +246,20 @@ const router = createBrowserRouter(
{/* report routes */}
<Route
element={<ProtectedReportLayout />}
handle={{ title: 'Flow report' }}
path="flows/:flowId/report"
/>
{/* public routes */}
<Route
element={<PublicLoginLayout />}
handle={{ title: 'Login' }}
path="login"
/>
<Route
element={<OAuthResult />}
handle={{ title: 'OAuth' }}
path="oauth/result"
/>
@@ -0,0 +1,172 @@
import { render, waitFor } from '@testing-library/react';
import { createMemoryRouter, Outlet, RouterProvider } from 'react-router-dom';
import { describe, expect, it } from 'vitest';
import { DocumentTitle } from './document-title';
const renderAt = (initialPath: string, routes: Parameters<typeof createMemoryRouter>[0]) => {
const router = createMemoryRouter(routes, { initialEntries: [initialPath] });
return render(<RouterProvider router={router} />);
};
describe('DocumentTitle', () => {
it('renders APP_NAME when no matched route exposes a title handle', async () => {
renderAt('/anywhere', [
{
// No child route handle — DocumentTitle should fall back to APP_NAME only.
children: [{ element: <span>page</span>, path: 'anywhere' }],
element: (
<>
<DocumentTitle />
<Outlet />
</>
),
path: '/',
},
]);
await waitFor(() => expect(document.title).toBe('PentAGI'));
});
it('renders a static title from handle', async () => {
renderAt('/dashboard', [
{
children: [{ element: <span>page</span>, handle: { title: 'Dashboard' }, path: 'dashboard' }],
element: (
<>
<DocumentTitle />
<Outlet />
</>
),
path: '/',
},
]);
await waitFor(() => expect(document.title).toBe('Dashboard — PentAGI'));
});
it('renders a derived title from a handle.title function reading params', async () => {
renderAt('/prompts/agentSelector', [
{
children: [
{
element: <span>page</span>,
handle: {
title: ({ promptId }: Record<string, string | undefined>) =>
promptId
? promptId.replaceAll(/([A-Z])/g, ' $1').replace(/^./, (s) => s.toUpperCase())
: 'Prompt',
},
path: 'prompts/:promptId',
},
],
element: (
<>
<DocumentTitle />
<Outlet />
</>
),
path: '/',
},
]);
await waitFor(() => expect(document.title).toBe('Agent Selector — PentAGI'));
});
it('renders the deepest matching handle (child wins over parent)', async () => {
renderAt('/settings/api-tokens', [
{
children: [
{
children: [
{ element: <span>tokens</span>, handle: { title: 'API tokens' }, path: 'api-tokens' },
],
element: <Outlet />,
handle: { title: 'Settings' },
path: 'settings',
},
],
element: (
<>
<DocumentTitle />
<Outlet />
</>
),
path: '/',
},
]);
await waitFor(() => expect(document.title).toBe('API tokens — PentAGI'));
});
it('renders a title from handle.titleComponent', async () => {
function CustomTitle({ params }: { params: Record<string, string | undefined> }) {
// React 19 only hoists <title> when the child is a single string
// (not text + interpolation + text), so compute the string ahead.
return <title>{`Custom #${params.id} — PentAGI`}</title>;
}
renderAt('/items/42', [
{
children: [{ element: <span>page</span>, handle: { titleComponent: CustomTitle }, path: 'items/:id' }],
element: (
<>
<DocumentTitle />
<Outlet />
</>
),
path: '/',
},
]);
await waitFor(() => expect(document.title).toBe('Custom #42 — PentAGI'));
});
it('prefers titleComponent over static title when both are present on the same handle', async () => {
function CustomTitle() {
return <title>From component PentAGI</title>;
}
renderAt('/x', [
{
children: [
{
element: <span>page</span>,
handle: { title: 'From static', titleComponent: CustomTitle },
path: 'x',
},
],
element: (
<>
<DocumentTitle />
<Outlet />
</>
),
path: '/',
},
]);
await waitFor(() => expect(document.title).toBe('From component — PentAGI'));
});
it('falls back to APP_NAME when handle.title returns an empty string', async () => {
renderAt('/x', [
{
children: [{ element: <span>page</span>, handle: { title: '' }, path: 'x' }],
element: (
<>
<DocumentTitle />
<Outlet />
</>
),
path: '/',
},
]);
// An empty string from the resolver is treated as "no title" — fall back
// to APP_NAME alone. This guards the route-level convention: pages that
// do not want a prefix can return '' instead of omitting the handle.
await waitFor(() => expect(document.title).toBe('PentAGI'));
});
});
@@ -2,124 +2,82 @@ 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;
/**
* Static string or a pure function of route params. Use for routes
* where the title is known synchronously — listing pages, /new routes,
* params-derived titles like `/settings/prompts/:promptId`.
*/
title?: TitleResolver;
/**
* Component that renders `<title>` and may subscribe to Apollo cache or
* other reactive sources. Use when the title depends on resource data
* that needs to react to cache updates after the page mounts.
*/
titleComponent?: TitleComponent;
}
type RouteParams = Record<string, string | undefined>;
type TitleComponent = ComponentType<{ params: RouteParams }>;
const hasTitleComponent = (handle: unknown): handle is RouteHandleWithTitle =>
type TitleResolver = ((params: RouteParams) => string) | string;
const hasTitle = (handle: unknown): handle is { title: TitleResolver } => {
if (typeof handle !== 'object' || handle === null || !('title' in handle)) {
return false;
}
const value = (handle as { title: unknown }).title;
return typeof value === 'string' || typeof value === 'function';
};
const hasTitleComponent = (handle: unknown): handle is { titleComponent: TitleComponent } =>
typeof handle === 'object' &&
handle !== null &&
'titleComponent' in handle &&
typeof (handle as RouteHandleWithTitle).titleComponent === 'function';
typeof (handle as { titleComponent: unknown }).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.
* Renders the document `<title>` driven by react-router route handles. The
* deepest match exposing `handle.title` or `handle.titleComponent` wins.
*
* Routes without a `titleComponent` keep using `<PageTitle>` inside the page
* component — the migration is route-by-route.
* - `handle.title: string` — static (e.g. "Dashboard").
* - `handle.title: (params) => string` — derived from route params
* (e.g. `/settings/prompts/:promptId` formatting the id).
* - `handle.titleComponent` — reactive component that subscribes to Apollo
* cache for resource-driven titles that need to react to cache updates.
*
* Living in the app shell, this component survives navigation between
* sibling detail routes — fixing the previous "Provider — PentAGI" flash
* during DetailNavigation prev/next.
*/
export function DocumentTitle() {
const matches = useMatches();
const match = matches.findLast((m) => hasTitleComponent(m.handle) || hasTitle(m.handle));
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} />;
}
if (!match) {
return renderTitle(null);
}
return null;
}
const handle = match.handle;
// 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.
if (hasTitleComponent(handle)) {
const TitleComponent = handle.titleComponent;
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 <TitleComponent params={match.params} />;
}
return renderTitle(data?.knowledgeDocument?.question || 'Knowledge');
}
if (hasTitle(handle)) {
const resolved = typeof handle.title === 'function' ? handle.title(match.params) : handle.title;
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');
return renderTitle(resolved);
}
// 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');
return renderTitle(null);
}
@@ -1,23 +0,0 @@
const APP_NAME = 'PentAGI';
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".
*/
children?: null | string;
}
/**
* Renders a `<title>` element that React 19 hoists into <head> automatically.
*
* 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) {
return <title>{children ? `${children}${APP_NAME}` : APP_NAME}</title>;
}
@@ -0,0 +1,80 @@
import {
useFlowQuery,
useFlowTemplateQuery,
useKnowledgeDocumentQuery,
useSettingsProvidersQuery,
} from '@/graphql/types';
const APP_NAME = 'PentAGI';
type RouteParams = Record<string, string | undefined>;
const renderTitle = (label: null | string) => <title>{label ? `${label}${APP_NAME}` : APP_NAME}</title>;
// Reactive title components used by detail routes via `handle.titleComponent`.
// Each uses `fetchPolicy: 'cache-only'` so it subscribes to the Apollo cache
// without issuing a duplicate HTTP request — the destination page's own
// query fills the cache. 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');
}
const provider = data?.settingsProviders.userDefined?.find((candidate) => String(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');
}
@@ -0,0 +1,7 @@
/**
* Converts a camelCase prompt key (e.g. "agentSelector") into a display
* label ("Agent Selector"). Used in the prompt detail page and route
* handle title to keep the two in sync.
*/
export const formatPromptId = (key: string): string =>
key.replaceAll(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase());
@@ -1,7 +1,6 @@
import { LayoutDashboard } from 'lucide-react';
import { useState, useTransition } from 'react';
import { PageTitle } from '@/components/shared/page-title';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Separator } from '@/components/ui/separator';
import { SidebarTrigger } from '@/components/ui/sidebar';
@@ -75,7 +74,6 @@ function Dashboard() {
return (
<>
<PageTitle>Dashboard</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 items-center gap-2 px-4">
<SidebarTrigger className="-ml-1" />
-4
View File
@@ -3,7 +3,6 @@ import { useParams, useSearchParams } from 'react-router-dom';
import Logo from '@/components/icons/logo';
import Markdown from '@/components/shared/markdown';
import { PageTitle } from '@/components/shared/page-title';
import { useFlowReportQuery } from '@/graphql/types';
import { Log } from '@/lib/log';
import { generateFileName, generatePDFFromMarkdown, generateReport } from '@/lib/report';
@@ -94,7 +93,6 @@ function FlowReport() {
if (state === 'loading' || state === 'generating') {
return (
<div className="min-h-screen bg-linear-to-br from-blue-50 via-white to-purple-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<PageTitle>Flow report</PageTitle>
<div className="flex min-h-screen flex-col items-center justify-center p-8">
<Logo className="animate-logo-spin mb-8 size-16 text-white" />
<div className="flex flex-col gap-4 text-center">
@@ -116,7 +114,6 @@ function FlowReport() {
if (state === 'error') {
return (
<div className="min-h-screen bg-linear-to-br from-red-50 via-white to-orange-50 dark:from-gray-900 dark:via-gray-800 dark:to-gray-900">
<PageTitle>Flow report</PageTitle>
<div className="flex min-h-screen flex-col items-center justify-center p-8">
<Logo className="mb-8 size-16" />
<div className="flex flex-col gap-4 text-center">
@@ -138,7 +135,6 @@ function FlowReport() {
return (
<div className="min-h-screen bg-white dark:bg-gray-900">
<PageTitle>Flow report</PageTitle>
<div className="h-screen w-full overflow-auto p-8">
<div className="mx-auto max-w-4xl">
<div className="prose prose-slate dark:prose-invert max-w-none">
+1 -1
View File
@@ -122,7 +122,7 @@ function Flow() {
}
// Drop the new title into the optimistic state immediately so the
// breadcrumb/tab/PageTitle flip before the network round-trip. The
// breadcrumb and document title flip before the network round-trip. The
// optimistic value lives only inside this transition — once the
// mutation settles, useOptimistic falls back to the Apollo cache
// (which the mutation response has already updated on success, or
-4
View File
@@ -13,7 +13,6 @@ import { ProviderIcon } from '@/components/icons/provider-icon';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { HeaderButton } from '@/components/shared/header-button';
import { InlineEditInput } 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';
@@ -629,7 +628,6 @@ function Flows() {
if (isLoading) {
return (
<>
<PageTitle>Flows</PageTitle>
{pageHeader}
<div className="flex flex-col gap-4 p-4">
<StatusCard
@@ -646,7 +644,6 @@ function Flows() {
if (flows.length === 0) {
return (
<>
<PageTitle>Flows</PageTitle>
{pageHeader}
<div className="flex flex-col gap-4 p-4">
<StatusCard
@@ -670,7 +667,6 @@ function Flows() {
return (
<>
<PageTitle>Flows</PageTitle>
{pageHeader}
<div className="flex flex-col gap-4 p-4 pt-0">
<DataTable<Flow>
-2
View File
@@ -1,7 +1,6 @@
import { useMemo, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { PageTitle } from '@/components/shared/page-title';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Card, CardContent } from '@/components/ui/card';
import { Separator } from '@/components/ui/separator';
@@ -48,7 +47,6 @@ function NewFlow() {
return (
<>
<PageTitle>New flow</PageTitle>
<header className="bg-background sticky top-0 z-10 flex h-12 shrink-0 items-center gap-2 border-b px-4">
<SidebarTrigger className="-ml-1 shrink-0" />
<Separator
@@ -10,7 +10,6 @@ import type { BadgeVariant } from '@/components/ui/badge';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { HeaderButton } from '@/components/shared/header-button';
import { InlineEditInput } 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';
@@ -395,7 +394,6 @@ function Knowledges() {
if (isLoading && !knowledges.length) {
return (
<>
<PageTitle>Knowledges</PageTitle>
{pageHeader}
<div className="flex flex-col gap-4 p-4">
<StatusCard
@@ -411,7 +409,6 @@ function Knowledges() {
if (!knowledges.length) {
return (
<>
<PageTitle>Knowledges</PageTitle>
{pageHeader}
<div className="flex flex-col gap-4 p-4">
<StatusCard
@@ -435,7 +432,6 @@ function Knowledges() {
return (
<>
<PageTitle>Knowledges</PageTitle>
{pageHeader}
<div className="flex flex-col gap-4 p-4 pt-0">
<DataTable
-2
View File
@@ -2,7 +2,6 @@ import { Loader2 } from 'lucide-react';
import { useLocation, useSearchParams } from 'react-router-dom';
import Logo from '@/components/icons/logo';
import { PageTitle } from '@/components/shared/page-title';
import LoginForm from '@/features/authentication/login-form';
import { getSafeReturnUrl } from '@/lib/utils/auth';
import { useUser } from '@/providers/user-provider';
@@ -18,7 +17,6 @@ function Login() {
return (
<div className="flex h-dvh w-full items-center justify-center">
<PageTitle>Login</PageTitle>
<div className="h-dvh w-full lg:grid lg:grid-cols-2">
<div className="flex items-center justify-center px-4 py-12">
{!isLoading ? (
-2
View File
@@ -1,7 +1,6 @@
import { useEffect, useLayoutEffect, useRef, useState } from 'react';
import Logo from '@/components/icons/logo';
import { PageTitle } from '@/components/shared/page-title';
function OAuthResult() {
const [statusMessage, setStatusMessage] = useState('Authentication in progress...');
@@ -111,7 +110,6 @@ function OAuthResult() {
return (
<div className="flex h-screen w-full items-center justify-center bg-linear-to-r from-slate-800 to-slate-950">
<PageTitle>OAuth</PageTitle>
<Logo className="animate-logo-spin m-auto size-32 text-white delay-10000" />
<div className="fixed bottom-4 text-sm text-white">{statusMessage}</div>
</div>
@@ -25,7 +25,6 @@ import {
} from '@/components/shared/file-manager';
import { HeaderButton } from '@/components/shared/header-button';
import { OverwriteDialog, useOverwrite } from '@/components/shared/overwrite';
import { PageTitle } from '@/components/shared/page-title';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import {
@@ -449,7 +448,6 @@ function Resources() {
return (
<>
<PageTitle>Resources</PageTitle>
{pageHeader}
<div
className="relative flex h-[calc(100dvh-3rem)] flex-col gap-4 p-4"
@@ -25,7 +25,6 @@ import * as z from 'zod';
import type { ApiTokenFragmentFragment } from '@/graphql/types';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { PageTitle } from '@/components/shared/page-title';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -864,7 +863,6 @@ function SettingsAPITokens() {
if (isLoading) {
return (
<div className="flex flex-col gap-4">
<PageTitle>API tokens</PageTitle>
<SettingsAPITokensHeader onCreateClick={handleCreateNew} />
<StatusCard
description="Please wait while we fetch your API tokens"
@@ -878,7 +876,6 @@ function SettingsAPITokens() {
if (error) {
return (
<div className="flex flex-col gap-4">
<PageTitle>API tokens</PageTitle>
<SettingsAPITokensHeader onCreateClick={handleCreateNew} />
<Alert variant="destructive">
<AlertCircle className="size-4" />
@@ -894,7 +891,6 @@ function SettingsAPITokens() {
if (tokens.length === 0 && !creatingToken) {
return (
<div className="flex flex-col gap-4">
<PageTitle>API tokens</PageTitle>
<SettingsAPITokensHeader onCreateClick={handleCreateNew} />
<StatusCard
action={
@@ -916,7 +912,6 @@ function SettingsAPITokens() {
return (
<div className="flex flex-col gap-4">
<PageTitle>API tokens</PageTitle>
<SettingsAPITokensHeader onCreateClick={handleCreateNew} />
{(createError || updateError || deleteError || deleteErrorMessage) && (
@@ -21,7 +21,6 @@ import { z } from 'zod';
import type { AgentPrompt, AgentPrompts, DefaultPrompt, PromptType } from '@/graphql/types';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { PageTitle } from '@/components/shared/page-title';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Button } from '@/components/ui/button';
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from '@/components/ui/dialog';
@@ -38,6 +37,7 @@ import {
useValidatePromptMutation,
} from '@/graphql/types';
import { cn } from '@/lib/utils';
import { formatPromptId } from '@/lib/utils/format-prompt-id';
// Form schemas for each tab
const systemFormSchema = z.object({
@@ -95,11 +95,6 @@ function FormTextareaItem({ className, control, disabled, label, name, placehold
);
}
// Helper function to format display name
const formatName = (key: string): string => {
return key.replaceAll(/([A-Z])/g, ' $1').replace(/^./, (str) => str.toUpperCase());
};
// Helper function to extract used variables from template
const getUsedVariables = (template: string | undefined): Set<string> => {
const usedVariables = new Set<string>();
@@ -328,7 +323,7 @@ function SettingsPrompt() {
data: agentData,
defaultHumanTemplate: (agentData as AgentPrompts)?.human?.template || '',
defaultSystemTemplate: agentData?.system?.template || '',
displayName: formatName(promptId),
displayName: formatPromptId(promptId),
hasHuman: !!(agentData as AgentPrompts)?.human,
humanTemplate: userHumanPrompt?.template || (agentData as AgentPrompts)?.human?.template || '',
systemTemplate: userSystemPrompt?.template || agentData?.system?.template || '',
@@ -348,7 +343,7 @@ function SettingsPrompt() {
data: toolData,
defaultHumanTemplate: '',
defaultSystemTemplate: toolData?.template || '',
displayName: formatName(promptId),
displayName: formatPromptId(promptId),
hasHuman: false,
humanTemplate: '',
systemTemplate: userToolPrompt?.template || toolData?.template || '',
@@ -591,7 +586,6 @@ function SettingsPrompt() {
if (loading) {
return (
<>
<PageTitle>{promptInfo?.displayName ?? 'Prompt'}</PageTitle>
<StatusCard
description="Please wait while we fetch prompt information"
icon={<Loader2 className="text-muted-foreground size-16 animate-spin" />}
@@ -605,7 +599,6 @@ function SettingsPrompt() {
if (error) {
return (
<>
<PageTitle>{promptInfo?.displayName ?? 'Prompt'}</PageTitle>
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Error loading prompt data</AlertTitle>
@@ -619,7 +612,6 @@ function SettingsPrompt() {
if (!promptInfo) {
return (
<>
<PageTitle>{promptInfo?.displayName ?? 'Prompt'}</PageTitle>
<Alert variant="destructive">
<AlertCircle className="size-4" />
<AlertTitle>Prompt not found</AlertTitle>
@@ -719,7 +711,6 @@ function SettingsPrompt() {
return (
<div className="flex flex-col gap-4">
<PageTitle>{promptInfo.displayName}</PageTitle>
<div className="flex flex-col gap-2">
<h2 className="flex items-center gap-2 text-lg font-semibold">
{promptInfo.type === 'agent' ? (
@@ -21,7 +21,6 @@ import { useNavigate } from 'react-router-dom';
import type { AgentPrompt, AgentPrompts, DefaultPrompt, PromptType } from '@/graphql/types';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { PageTitle } from '@/components/shared/page-title';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -798,7 +797,6 @@ function SettingsPrompts() {
if (isLoading) {
return (
<div className="flex flex-col gap-4">
<PageTitle>Prompts</PageTitle>
<SettingsPromptsHeader />
<StatusCard
description="Please wait while we fetch your prompt templates"
@@ -812,7 +810,6 @@ function SettingsPrompts() {
if (error) {
return (
<div className="flex flex-col gap-4">
<PageTitle>Prompts</PageTitle>
<SettingsPromptsHeader />
<Alert variant="destructive">
<AlertCircle className="size-4" />
@@ -829,7 +826,6 @@ function SettingsPrompts() {
if (agentPrompts.length === 0 && toolPrompts.length === 0) {
return (
<div className="flex flex-col gap-4">
<PageTitle>Prompts</PageTitle>
<SettingsPromptsHeader />
<StatusCard
description="Prompt templates could not be loaded"
@@ -842,7 +838,6 @@ function SettingsPrompts() {
return (
<Fragment>
<PageTitle>Prompts</PageTitle>
<div className="flex flex-col gap-6">
<SettingsPromptsHeader />
@@ -19,7 +19,6 @@ import Ollama from '@/components/icons/ollama';
import OpenAi from '@/components/icons/open-ai';
import Qwen from '@/components/icons/qwen';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { PageTitle } from '@/components/shared/page-title';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
@@ -390,7 +389,6 @@ function SettingsProviders() {
if (isLoading) {
return (
<div className="flex flex-col gap-4">
<PageTitle>Providers</PageTitle>
<SettingsProvidersHeader />
<StatusCard
description="Please wait while we fetch your provider configurations"
@@ -404,7 +402,6 @@ function SettingsProviders() {
if (error) {
return (
<div className="flex flex-col gap-4">
<PageTitle>Providers</PageTitle>
<SettingsProvidersHeader />
<Alert variant="destructive">
<AlertCircle className="size-4" />
@@ -421,7 +418,6 @@ function SettingsProviders() {
if (providers.length === 0) {
return (
<div className="flex flex-col gap-4">
<PageTitle>Providers</PageTitle>
<SettingsProvidersHeader />
<StatusCard
action={
@@ -443,7 +439,6 @@ function SettingsProviders() {
return (
<div className="flex flex-col gap-4">
<PageTitle>Providers</PageTitle>
<SettingsProvidersHeader />
{/* Delete Error Alert */}
@@ -8,7 +8,6 @@ import { toast } from 'sonner';
import ConfirmationDialog from '@/components/shared/confirmation-dialog';
import { HeaderButton } from '@/components/shared/header-button';
import { InlineEditInput } from '@/components/shared/inline-edit';
import { PageTitle } from '@/components/shared/page-title';
import { Breadcrumb, BreadcrumbItem, BreadcrumbList, BreadcrumbPage } from '@/components/ui/breadcrumb';
import { Button } from '@/components/ui/button';
import { ContextMenuItem, ContextMenuSeparator } from '@/components/ui/context-menu';
@@ -274,7 +273,6 @@ function Templates() {
if (!templates.length) {
return (
<>
<PageTitle>Templates</PageTitle>
{pageHeader}
<div className="flex flex-col gap-4 p-4">
<StatusCard
@@ -298,7 +296,6 @@ function Templates() {
return (
<>
<PageTitle>Templates</PageTitle>
{pageHeader}
<div className="flex flex-col gap-4 p-4 pt-0">
<DataTable