From 0eca857b86239a2afef981336b4b0d699475eea0 Mon Sep 17 00:00:00 2001 From: Bill Chirico Date: Sat, 7 Mar 2026 21:18:55 -0500 Subject: [PATCH] fix(dashboard): path boundary guards + title sync + AGENTS.md docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - page-titles.ts: leaf matchers now use exact equality OR subtree startsWith to prevent false-positive matches (e.g. /dashboard/ai != /dashboard/airline) - dashboard-title-sync.tsx: guard against overwriting more-specific page-level metadata titles set by Next.js — only update if title is missing or generic - page-titles.test.ts: add path-boundary and subtree match test cases - AGENTS.md: document dashboardTitleMatchers pattern for new routes --- AGENTS.md | 8 +++++ .../layout/dashboard-title-sync.tsx | 21 ++++++++++-- web/src/lib/page-titles.ts | 32 ++++++++++++------- web/tests/lib/page-titles.test.ts | 18 +++++++++++ 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 773caf84..ebc27ddd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -99,6 +99,13 @@ web/ # Next.js dashboard - Mobile-responsive design - Real-time updates via WebSocket +#### Dashboard Tab Titles +Browser tab titles are managed via two mechanisms: +- **SSR entry points** (`/dashboard`, `/dashboard/config`, `/dashboard/performance`): export `metadata` using `createPageMetadata()` from `web/src/lib/page-titles.ts` +- **Client-side navigations**: `DashboardTitleSync` component (mounted in the dashboard shell) syncs `document.title` using `getDashboardDocumentTitle()` + +**When adding a new dashboard route**, you must add a matcher entry to `dashboardTitleMatchers` in `web/src/lib/page-titles.ts`. Use exact equality for leaf routes (`pathname === '/dashboard/my-route'`) plus a subtree check (`pathname.startsWith('/dashboard/my-route/')`) to avoid false-positive matches on future sibling routes. For SSR entry points, also export `metadata` from the page file using `createPageMetadata(title)`. + ## Common Tasks ### Adding a New Feature @@ -109,6 +116,7 @@ web/ # Next.js dashboard 5. Create database migration if needed 6. Write tests in `tests/` 7. Update dashboard UI if configurable +8. If adding a new dashboard route, add a matcher entry to `dashboardTitleMatchers` in `web/src/lib/page-titles.ts` (see Web Dashboard section above) ### Adding a New Command 1. Create file in `src/commands/` diff --git a/web/src/components/layout/dashboard-title-sync.tsx b/web/src/components/layout/dashboard-title-sync.tsx index 363ec07d..1caa4f4d 100644 --- a/web/src/components/layout/dashboard-title-sync.tsx +++ b/web/src/components/layout/dashboard-title-sync.tsx @@ -2,13 +2,30 @@ import { usePathname } from 'next/navigation'; import { useEffect } from 'react'; -import { getDashboardDocumentTitle } from '@/lib/page-titles'; +import { APP_TITLE, getDashboardDocumentTitle } from '@/lib/page-titles'; +/** + * Syncs `document.title` on client-side navigations. + * + * Guards against overwriting a more specific title that Next.js already set + * from a page's `metadata` export: if the current title already ends with + * APP_TITLE but has a *different* page-section prefix than what this component + * would produce, we assume the page set a more specific title and leave it alone. + */ export function DashboardTitleSync() { const pathname = usePathname(); useEffect(() => { - document.title = getDashboardDocumentTitle(pathname); + const computed = getDashboardDocumentTitle(pathname); + const current = document.title; + + // If the current title already ends with our app suffix and is more specific + // than what we'd set (i.e. different prefix), respect the page-level metadata. + if (current.endsWith(APP_TITLE) && current !== computed && current !== APP_TITLE) { + return; + } + + document.title = computed; }, [pathname]); return null; diff --git a/web/src/lib/page-titles.ts b/web/src/lib/page-titles.ts index 77d8c6b3..62152fc4 100644 --- a/web/src/lib/page-titles.ts +++ b/web/src/lib/page-titles.ts @@ -25,47 +25,57 @@ const dashboardTitleMatchers: DashboardTitleMatcher[] = [ title: 'Overview', }, { - matches: (pathname) => pathname.startsWith('/dashboard/moderation'), + matches: (pathname) => + pathname === '/dashboard/moderation' || pathname.startsWith('/dashboard/moderation/'), title: 'Moderation', }, { - matches: (pathname) => pathname.startsWith('/dashboard/temp-roles'), + matches: (pathname) => + pathname === '/dashboard/temp-roles' || pathname.startsWith('/dashboard/temp-roles/'), title: 'Temp Roles', }, { - matches: (pathname) => pathname.startsWith('/dashboard/ai'), + matches: (pathname) => pathname === '/dashboard/ai' || pathname.startsWith('/dashboard/ai/'), title: 'AI Chat', }, { - matches: (pathname) => pathname.startsWith('/dashboard/members'), + matches: (pathname) => + pathname === '/dashboard/members' || pathname.startsWith('/dashboard/members/'), title: 'Members', }, { - matches: (pathname) => pathname.startsWith('/dashboard/conversations'), + matches: (pathname) => + pathname === '/dashboard/conversations' || pathname.startsWith('/dashboard/conversations/'), title: 'Conversations', }, { - matches: (pathname) => pathname.startsWith('/dashboard/tickets'), + matches: (pathname) => + pathname === '/dashboard/tickets' || pathname.startsWith('/dashboard/tickets/'), title: 'Tickets', }, { - matches: (pathname) => pathname.startsWith('/dashboard/config'), + matches: (pathname) => + pathname === '/dashboard/config' || pathname.startsWith('/dashboard/config/'), title: 'Bot Config', }, { - matches: (pathname) => pathname.startsWith('/dashboard/audit-log'), + matches: (pathname) => + pathname === '/dashboard/audit-log' || pathname.startsWith('/dashboard/audit-log/'), title: 'Audit Log', }, { - matches: (pathname) => pathname.startsWith('/dashboard/performance'), + matches: (pathname) => + pathname === '/dashboard/performance' || pathname.startsWith('/dashboard/performance/'), title: 'Performance', }, { - matches: (pathname) => pathname.startsWith('/dashboard/logs'), + matches: (pathname) => + pathname === '/dashboard/logs' || pathname.startsWith('/dashboard/logs/'), title: 'Logs', }, { - matches: (pathname) => pathname.startsWith('/dashboard/settings'), + matches: (pathname) => + pathname === '/dashboard/settings' || pathname.startsWith('/dashboard/settings/'), title: 'Settings', }, ]; diff --git a/web/tests/lib/page-titles.test.ts b/web/tests/lib/page-titles.test.ts index b161f015..4a8ae434 100644 --- a/web/tests/lib/page-titles.test.ts +++ b/web/tests/lib/page-titles.test.ts @@ -19,6 +19,24 @@ describe('page titles', () => { expect(getDashboardPageTitle('/dashboard/conversations/abc')).toBe('Conversation Details'); expect(getDashboardPageTitle('/dashboard/tickets/42')).toBe('Ticket Details'); expect(getDashboardPageTitle('/dashboard/unknown')).toBeNull(); + // All leaf routes should match exactly and within subtree + expect(getDashboardPageTitle('/dashboard/ai')).toBe('AI Chat'); + expect(getDashboardPageTitle('/dashboard/ai/settings')).toBe('AI Chat'); + expect(getDashboardPageTitle('/dashboard/config')).toBe('Bot Config'); + expect(getDashboardPageTitle('/dashboard/config/advanced')).toBe('Bot Config'); + expect(getDashboardPageTitle('/dashboard/audit-log')).toBe('Audit Log'); + expect(getDashboardPageTitle('/dashboard/performance')).toBe('Performance'); + }); + + it('does not produce false-positive matches on shared prefixes (path boundary)', () => { + // /dashboard/ai must NOT match a hypothetical /dashboard/airline route + expect(getDashboardPageTitle('/dashboard/airline')).toBeNull(); + // /dashboard/logs must NOT match /dashboard/logs-archive + expect(getDashboardPageTitle('/dashboard/logs-archive')).toBeNull(); + // /dashboard/settings must NOT match /dashboard/settings-v2 + expect(getDashboardPageTitle('/dashboard/settings-v2')).toBeNull(); + // /dashboard/moderation must NOT match /dashboard/moderation-v2 + expect(getDashboardPageTitle('/dashboard/moderation-v2')).toBeNull(); }); it('builds complete document titles from dashboard routes', () => {