Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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/`
Expand Down
21 changes: 19 additions & 2 deletions web/src/components/layout/dashboard-title-sync.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Comment on lines +24 to +26
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Guard fires on every client-side navigation, stalling the title

The condition current.endsWith(APP_TITLE) will match titles that DashboardTitleSync itself previously set (e.g. "Bot Config - Volvox.Bot - AI Powered Discord Bot"). Because the component runs in the persistent dashboard shell and sets the title on every navigation, the next navigation will always see a current that ends with APP_TITLE (set by the previous run) but differs from the new computed. All three guard conditions will be true, the early return fires, and the title is never updated after the first page visit.

Concrete trace:

  1. /dashboard/configDashboardTitleSync sets title to "Bot Config - Volvox.Bot - AI Powered Discord Bot"
  2. Navigate to /dashboard/aiDashboardTitleSync fires:
    • computed = "AI Chat - Volvox.Bot - AI Powered Discord Bot"
    • current = "Bot Config - Volvox.Bot - AI Powered Discord Bot" ← set by step 1
    • current.endsWith(APP_TITLE)true
    • current !== computedtrue
    • current !== APP_TITLEtrue
    • Guard returns early → title stuck as "Bot Config - ..."

Furthermore, the guard is designed to protect titles set by a page's metadata export. However, createPageMetadata() returns bare titles (e.g. { title: 'Bot Config' }) without the APP_TITLE suffix, so current.endsWith(APP_TITLE) would be false for those titles anyway — meaning the guard never fires for its intended use case.

A reliable fix is to track what DashboardTitleSync last set via a ref so the guard can distinguish "title set externally by page metadata" from "stale title set by this component":

import { useRef } from 'react';

const prevComputedRef = useRef<string | null>(null);

useEffect(() => {
  const computed = getDashboardDocumentTitle(pathname);
  const current = document.title;

  // Only respect a page-level title if it wasn't set by this component on the
  // previous navigation.
  if (
    current.endsWith(APP_TITLE) &&
    current !== computed &&
    current !== APP_TITLE &&
    current !== prevComputedRef.current
  ) {
    prevComputedRef.current = null;
    return;
  }

  document.title = computed;
  prevComputedRef.current = computed;
}, [pathname]);
Prompt To Fix With AI
This is a comment left during a code review.
Path: web/src/components/layout/dashboard-title-sync.tsx
Line: 24-26

Comment:
Guard fires on every client-side navigation, stalling the title

The condition `current.endsWith(APP_TITLE)` will match titles that `DashboardTitleSync` itself previously set (e.g. `"Bot Config - Volvox.Bot - AI Powered Discord Bot"`). Because the component runs in the persistent dashboard shell and sets the title on every navigation, the *next* navigation will always see a `current` that ends with `APP_TITLE` (set by the previous run) but differs from the new `computed`. All three guard conditions will be true, the early return fires, and the title is **never updated** after the first page visit.

Concrete trace:
1. `/dashboard/config``DashboardTitleSync` sets title to `"Bot Config - Volvox.Bot - AI Powered Discord Bot"`
2. Navigate to `/dashboard/ai``DashboardTitleSync` fires:
   - `computed = "AI Chat - Volvox.Bot - AI Powered Discord Bot"`
   - `current = "Bot Config - Volvox.Bot - AI Powered Discord Bot"` ← set by step 1
   - `current.endsWith(APP_TITLE)`**true**
   - `current !== computed`**true**
   - `current !== APP_TITLE`**true**
   - Guard returns early → title stuck as `"Bot Config - ..."` ❌

Furthermore, the guard is designed to protect titles set by a page's `metadata` export. However, `createPageMetadata()` returns bare titles (e.g. `{ title: 'Bot Config' }`) without the `APP_TITLE` suffix, so `current.endsWith(APP_TITLE)` would be **false** for those titles anyway — meaning the guard never fires for its intended use case.

A reliable fix is to track what `DashboardTitleSync` last set via a ref so the guard can distinguish "title set externally by page metadata" from "stale title set by this component":

```javascript
import { useRef } from 'react';
…
const prevComputedRef = useRef<string | null>(null);

useEffect(() => {
  const computed = getDashboardDocumentTitle(pathname);
  const current = document.title;

  // Only respect a page-level title if it wasn't set by this component on the
  // previous navigation.
  if (
    current.endsWith(APP_TITLE) &&
    current !== computed &&
    current !== APP_TITLE &&
    current !== prevComputedRef.current
  ) {
    prevComputedRef.current = null;
    return;
  }

  document.title = computed;
  prevComputedRef.current = computed;
}, [pathname]);
```

How can I resolve this? If you propose a fix, please make it concise.


document.title = computed;
}, [pathname]);

return null;
Expand Down
32 changes: 21 additions & 11 deletions web/src/lib/page-titles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
},
];
Expand Down
18 changes: 18 additions & 0 deletions web/tests/lib/page-titles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading