diff --git a/web/src/components/layout/dashboard-title-sync.tsx b/web/src/components/layout/dashboard-title-sync.tsx
new file mode 100644
index 000000000..363ec07d6
--- /dev/null
+++ b/web/src/components/layout/dashboard-title-sync.tsx
@@ -0,0 +1,15 @@
+'use client';
+
+import { usePathname } from 'next/navigation';
+import { useEffect } from 'react';
+import { getDashboardDocumentTitle } from '@/lib/page-titles';
+
+export function DashboardTitleSync() {
+ const pathname = usePathname();
+
+ useEffect(() => {
+ document.title = getDashboardDocumentTitle(pathname);
+ }, [pathname]);
+
+ return null;
+}
diff --git a/web/src/lib/page-titles.ts b/web/src/lib/page-titles.ts
new file mode 100644
index 000000000..77d8c6b3b
--- /dev/null
+++ b/web/src/lib/page-titles.ts
@@ -0,0 +1,110 @@
+import type { Metadata } from 'next';
+
+export const APP_TITLE = 'Volvox.Bot - AI Powered Discord Bot';
+
+interface DashboardTitleMatcher {
+ matches: (pathname: string) => boolean;
+ title: string;
+}
+
+const dashboardTitleMatchers: DashboardTitleMatcher[] = [
+ {
+ matches: (pathname) => pathname.startsWith('/dashboard/members/'),
+ title: 'Member Details',
+ },
+ {
+ matches: (pathname) => pathname.startsWith('/dashboard/conversations/'),
+ title: 'Conversation Details',
+ },
+ {
+ matches: (pathname) => pathname.startsWith('/dashboard/tickets/'),
+ title: 'Ticket Details',
+ },
+ {
+ matches: (pathname) => pathname === '/dashboard',
+ title: 'Overview',
+ },
+ {
+ matches: (pathname) => pathname.startsWith('/dashboard/moderation'),
+ title: 'Moderation',
+ },
+ {
+ matches: (pathname) => pathname.startsWith('/dashboard/temp-roles'),
+ title: 'Temp Roles',
+ },
+ {
+ matches: (pathname) => pathname.startsWith('/dashboard/ai'),
+ title: 'AI Chat',
+ },
+ {
+ matches: (pathname) => pathname.startsWith('/dashboard/members'),
+ title: 'Members',
+ },
+ {
+ matches: (pathname) => pathname.startsWith('/dashboard/conversations'),
+ title: 'Conversations',
+ },
+ {
+ matches: (pathname) => pathname.startsWith('/dashboard/tickets'),
+ title: 'Tickets',
+ },
+ {
+ matches: (pathname) => pathname.startsWith('/dashboard/config'),
+ title: 'Bot Config',
+ },
+ {
+ matches: (pathname) => pathname.startsWith('/dashboard/audit-log'),
+ title: 'Audit Log',
+ },
+ {
+ matches: (pathname) => pathname.startsWith('/dashboard/performance'),
+ title: 'Performance',
+ },
+ {
+ matches: (pathname) => pathname.startsWith('/dashboard/logs'),
+ title: 'Logs',
+ },
+ {
+ matches: (pathname) => pathname.startsWith('/dashboard/settings'),
+ title: 'Settings',
+ },
+];
+
+function normalizePathname(pathname: string | null | undefined): string | null {
+ if (!pathname) {
+ return null;
+ }
+
+ const trimmedPathname =
+ pathname.endsWith('/') && pathname !== '/' ? pathname.slice(0, -1) : pathname;
+ return trimmedPathname || '/';
+}
+
+export function formatDocumentTitle(pageTitle?: string | null): string {
+ return pageTitle ? `${pageTitle} - ${APP_TITLE}` : APP_TITLE;
+}
+
+export function getDashboardPageTitle(pathname: string | null | undefined): string | null {
+ const normalizedPathname = normalizePathname(pathname);
+ if (!normalizedPathname) {
+ return null;
+ }
+
+ const matchedRoute = dashboardTitleMatchers.find(({ matches }) => matches(normalizedPathname));
+ return matchedRoute?.title ?? null;
+}
+
+export function getDashboardDocumentTitle(pathname: string | null | undefined): string {
+ return formatDocumentTitle(getDashboardPageTitle(pathname));
+}
+
+export function createPageMetadata(title: string, description?: string): Metadata {
+ if (!description) {
+ return { title };
+ }
+
+ return {
+ title,
+ description,
+ };
+}
diff --git a/web/tests/components/layout/dashboard-shell.test.tsx b/web/tests/components/layout/dashboard-shell.test.tsx
index 5c858caf2..39d46a8f4 100644
--- a/web/tests/components/layout/dashboard-shell.test.tsx
+++ b/web/tests/components/layout/dashboard-shell.test.tsx
@@ -1,20 +1,24 @@
-import { describe, it, expect, vi } from "vitest";
-import { render, screen } from "@testing-library/react";
+import { describe, it, expect, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
// Mock child components — DashboardShell is now a server component
-vi.mock("@/components/layout/header", () => ({
+vi.mock('@/components/layout/header', () => ({
Header: () =>
,
}));
-vi.mock("@/components/layout/sidebar", () => ({
+vi.mock('@/components/layout/dashboard-title-sync', () => ({
+ DashboardTitleSync: () =>
,
+}));
+
+vi.mock('@/components/layout/sidebar', () => ({
Sidebar: () =>
,
}));
-vi.mock("@/components/layout/server-selector", () => ({
+vi.mock('@/components/layout/server-selector', () => ({
ServerSelector: () =>
Servers
,
}));
-import { DashboardShell } from "@/components/layout/dashboard-shell";
+import { DashboardShell } from '@/components/layout/dashboard-shell';
describe("DashboardShell", () => {
it("renders header, sidebar, and content", () => {
@@ -24,6 +28,7 @@ describe("DashboardShell", () => {
,
);
expect(screen.getByTestId("header")).toBeInTheDocument();
+ expect(screen.getByTestId("dashboard-title-sync")).toBeInTheDocument();
expect(screen.getByTestId("sidebar")).toBeInTheDocument();
expect(screen.getByTestId("content")).toBeInTheDocument();
});
diff --git a/web/tests/components/layout/dashboard-title-sync.test.tsx b/web/tests/components/layout/dashboard-title-sync.test.tsx
new file mode 100644
index 000000000..0fd20270d
--- /dev/null
+++ b/web/tests/components/layout/dashboard-title-sync.test.tsx
@@ -0,0 +1,53 @@
+import { render, waitFor } from '@testing-library/react';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { APP_TITLE } from '@/lib/page-titles';
+
+let mockPathname = '/dashboard';
+
+vi.mock('next/navigation', () => ({
+ usePathname: () => mockPathname,
+}));
+
+import { DashboardTitleSync } from '@/components/layout/dashboard-title-sync';
+
+describe('DashboardTitleSync', () => {
+ beforeEach(() => {
+ mockPathname = '/dashboard';
+ document.title = '';
+ });
+
+ it('sets the dashboard title from the current route', async () => {
+ mockPathname = '/dashboard/members';
+
+ render(
);
+
+ await waitFor(() => {
+ expect(document.title).toBe('Members - Volvox.Bot - AI Powered Discord Bot');
+ });
+ });
+
+ it('updates the title when the route changes', async () => {
+ const { rerender } = render(
);
+
+ await waitFor(() => {
+ expect(document.title).toBe('Overview - Volvox.Bot - AI Powered Discord Bot');
+ });
+
+ mockPathname = '/dashboard/tickets/42';
+ rerender(
);
+
+ await waitFor(() => {
+ expect(document.title).toBe('Ticket Details - Volvox.Bot - AI Powered Discord Bot');
+ });
+ });
+
+ it('falls back to the app title for unknown routes', async () => {
+ mockPathname = '/dashboard/something-weird';
+
+ render(
);
+
+ await waitFor(() => {
+ expect(document.title).toBe(APP_TITLE);
+ });
+ });
+});
diff --git a/web/tests/lib/page-titles.test.ts b/web/tests/lib/page-titles.test.ts
new file mode 100644
index 000000000..b161f0155
--- /dev/null
+++ b/web/tests/lib/page-titles.test.ts
@@ -0,0 +1,38 @@
+import { describe, expect, it } from 'vitest';
+import {
+ APP_TITLE,
+ createPageMetadata,
+ formatDocumentTitle,
+ getDashboardDocumentTitle,
+ getDashboardPageTitle,
+} from '@/lib/page-titles';
+
+describe('page titles', () => {
+ it('formats dashboard tab titles with the shared app title suffix', () => {
+ expect(formatDocumentTitle('Members')).toBe('Members - Volvox.Bot - AI Powered Discord Bot');
+ });
+
+ it('maps dashboard routes to the expected tab titles', () => {
+ expect(getDashboardPageTitle('/dashboard')).toBe('Overview');
+ expect(getDashboardPageTitle('/dashboard/members')).toBe('Members');
+ expect(getDashboardPageTitle('/dashboard/members/123')).toBe('Member Details');
+ expect(getDashboardPageTitle('/dashboard/conversations/abc')).toBe('Conversation Details');
+ expect(getDashboardPageTitle('/dashboard/tickets/42')).toBe('Ticket Details');
+ expect(getDashboardPageTitle('/dashboard/unknown')).toBeNull();
+ });
+
+ it('builds complete document titles from dashboard routes', () => {
+ expect(getDashboardDocumentTitle('/dashboard/audit-log')).toBe(
+ 'Audit Log - Volvox.Bot - AI Powered Discord Bot',
+ );
+ expect(getDashboardDocumentTitle('/dashboard/unknown')).toBe(APP_TITLE);
+ });
+
+ it('creates Next metadata objects with optional descriptions', () => {
+ expect(createPageMetadata('Performance')).toEqual({ title: 'Performance' });
+ expect(createPageMetadata('Bot Config', 'Manage settings')).toEqual({
+ title: 'Bot Config',
+ description: 'Manage settings',
+ });
+ });
+});