diff --git a/apps/start/src/hooks/use-project-document-title.ts b/apps/start/src/hooks/use-project-document-title.ts
new file mode 100644
index 00000000..8fdb0562
--- /dev/null
+++ b/apps/start/src/hooks/use-project-document-title.ts
@@ -0,0 +1,44 @@
+import { useEffect, useRef } from 'react';
+
+const BASE_SUFFIX = ' | OpenPanel.dev';
+
+function inject(title: string, projectName: string): string {
+ if (!title.endsWith(BASE_SUFFIX)) return title;
+ if (title.includes(` | ${projectName}${BASE_SUFFIX}`)) return title;
+ return title.replace(BASE_SUFFIX, ` | ${projectName}${BASE_SUFFIX}`);
+}
+
+// Browser tab titles are set by each route's `head()` (server + client).
+// Rather than threading project name through every route's loader/head,
+// we patch the title imperatively on the client whenever it changes —
+// observed via a MutationObserver on
. This keeps the change
+// localized to one hook mounted under the project layout.
+export function useProjectDocumentTitle(projectName: string | undefined) {
+ const lastApplied = useRef(null);
+
+ useEffect(() => {
+ if (!projectName) return;
+
+ const apply = () => {
+ const current = document.title;
+ if (current === lastApplied.current) return;
+ const next = inject(current, projectName);
+ if (next !== current) {
+ document.title = next;
+ }
+ lastApplied.current = document.title;
+ };
+
+ apply();
+
+ // Observe the whole rather than the element directly,
+ // so we still catch updates if React swaps the title node entirely.
+ const observer = new MutationObserver(apply);
+ observer.observe(document.head, {
+ childList: true,
+ characterData: true,
+ subtree: true,
+ });
+ return () => observer.disconnect();
+ }, [projectName]);
+}
diff --git a/apps/start/src/routes/_app.$organizationId.$projectId.tsx b/apps/start/src/routes/_app.$organizationId.$projectId.tsx
index b00437ec..8e28d7bb 100644
--- a/apps/start/src/routes/_app.$organizationId.$projectId.tsx
+++ b/apps/start/src/routes/_app.$organizationId.$projectId.tsx
@@ -1,4 +1,5 @@
import BillingPrompt from '@/components/organization/billing-prompt';
+import { useProjectDocumentTitle } from '@/hooks/use-project-document-title';
import { useTRPC } from '@/integrations/trpc/react';
import { PAGE_TITLES, createProjectTitle } from '@/utils/title';
import { FREE_PRODUCT_IDS } from '@openpanel/payments';
@@ -17,22 +18,33 @@ export const Route = createFileRoute('/_app/$organizationId/$projectId')({
};
},
loader: async ({ context, params }) => {
- await context.queryClient.prefetchQuery(
- context.trpc.organization.get.queryOptions({
- organizationId: params.organizationId,
- }),
- );
+ await Promise.all([
+ context.queryClient.prefetchQuery(
+ context.trpc.organization.get.queryOptions({
+ organizationId: params.organizationId,
+ }),
+ ),
+ context.queryClient.prefetchQuery(
+ context.trpc.project.getProjectWithClients.queryOptions({
+ projectId: params.projectId,
+ }),
+ ),
+ ]);
},
});
function ProjectDashboard() {
- const { organizationId } = Route.useParams();
+ const { organizationId, projectId } = Route.useParams();
const trpc = useTRPC();
const { data: organization } = useSuspenseQuery(
trpc.organization.get.queryOptions({
organizationId,
}),
);
+ const { data: project } = useSuspenseQuery(
+ trpc.project.getProjectWithClients.queryOptions({ projectId }),
+ );
+ useProjectDocumentTitle(project?.name);
if (
organization.subscriptionProductId &&