mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-24 12:06:36 +00:00
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:
co-authored by
Claude Opus 4.7
parent
3a67daaad8
commit
f2fb222807
+19
-7
@@ -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" />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 ? (
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user