diff --git a/apps/dashboard/package.json b/apps/dashboard/package.json index 058d015a6..34bdbacfb 100644 --- a/apps/dashboard/package.json +++ b/apps/dashboard/package.json @@ -6,7 +6,8 @@ "scripts": { "dev": "vite", "build": "tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "bun test" }, "dependencies": { "@monaco-editor/react": "^4.7.0", diff --git a/apps/dashboard/src/components/Breadcrumbs.tsx b/apps/dashboard/src/components/Breadcrumbs.tsx index 7cbfb6f48..2b46080b5 100644 --- a/apps/dashboard/src/components/Breadcrumbs.tsx +++ b/apps/dashboard/src/components/Breadcrumbs.tsx @@ -17,6 +17,7 @@ import { runPath, suitePath, } from '~/lib/navigation'; +import { type ProjectDisplayEntry, resolveProjectDisplayName } from '~/lib/project-display-name'; interface BreadcrumbSegment { label: string; @@ -33,7 +34,7 @@ function formatRunLabel(runId: string | undefined): string { function deriveSegments( matches: ReturnType, - projectNames: ReadonlyMap = new Map(), + projects: readonly ProjectDisplayEntry[] = [], ): BreadcrumbSegment[] { const segments: BreadcrumbSegment[] = []; @@ -46,7 +47,7 @@ function deriveSegments( if (routeId === '/' || routeId === '/_layout') continue; if (routeId.includes('/projects/$projectId') && params.projectId) { - const label = projectNames.get(params.projectId) ?? params.projectId; + const label = resolveProjectDisplayName(params.projectId, projects); const to = projectHomePath(params.projectId); if (!segments.some((s) => s.to === to)) { segments.push({ @@ -169,10 +170,7 @@ function deriveSegments( export function Breadcrumbs() { const matches = useMatches(); const { data: projectData } = useProjectList(); - const projectNames = new Map( - (projectData?.projects ?? []).map((project) => [project.id, project.name]), - ); - const segments = deriveSegments(matches, projectNames); + const segments = deriveSegments(matches, projectData?.projects); if (segments.length === 0) return null; diff --git a/apps/dashboard/src/components/ProjectChromeTitle.test.tsx b/apps/dashboard/src/components/ProjectChromeTitle.test.tsx new file mode 100644 index 000000000..a5dbd1976 --- /dev/null +++ b/apps/dashboard/src/components/ProjectChromeTitle.test.tsx @@ -0,0 +1,16 @@ +import { describe, expect, it } from 'bun:test'; +import { renderToStaticMarkup } from 'react-dom/server'; + +import { ProjectChromeTitle } from './ProjectChromeTitle'; + +describe('ProjectChromeTitle', () => { + it('renders the registry project name as the primary chrome title', () => { + const html = renderToStaticMarkup( + , + ); + + expect(html).toContain('WTG.AI.Prompts'); + expect(html).toContain('wtg-ai-prompts'); + expect(html.indexOf('WTG.AI.Prompts')).toBeLessThan(html.indexOf('wtg-ai-prompts')); + }); +}); diff --git a/apps/dashboard/src/components/ProjectChromeTitle.tsx b/apps/dashboard/src/components/ProjectChromeTitle.tsx new file mode 100644 index 000000000..e7e41adac --- /dev/null +++ b/apps/dashboard/src/components/ProjectChromeTitle.tsx @@ -0,0 +1,23 @@ +/** + * Project title used by project-scoped Dashboard chrome. + * + * The primary label is the registry display name. The URL-safe ID remains + * visible as secondary context when it differs, but routes still receive the ID. + */ + +export function ProjectChromeTitle({ + projectId, + displayName, +}: { + projectId: string; + displayName: string; +}) { + return ( +
+

{displayName}

+ {displayName !== projectId ? ( +

{projectId}

+ ) : null} +
+ ); +} diff --git a/apps/dashboard/src/components/Sidebar.tsx b/apps/dashboard/src/components/Sidebar.tsx index 696f4b6cd..a35bf735f 100644 --- a/apps/dashboard/src/components/Sidebar.tsx +++ b/apps/dashboard/src/components/Sidebar.tsx @@ -33,6 +33,7 @@ import { useRunList, useStudioConfig, } from '~/lib/api'; +import { resolveProjectDisplayName } from '~/lib/project-display-name'; import { formatRunLabel, timeAgo } from '~/lib/run-label'; import { useSidebarContext } from '~/lib/sidebar-context'; @@ -89,7 +90,7 @@ function BrandHeader({ projectId }: { projectId?: string }) { function useProjectDisplayName(projectId: string): string { const { data } = useProjectList(); - return data?.projects.find((project) => project.id === projectId)?.name ?? projectId; + return resolveProjectDisplayName(projectId, data?.projects); } export function Sidebar() { diff --git a/apps/dashboard/src/lib/navigation.test.ts b/apps/dashboard/src/lib/navigation.test.ts index b735b2397..d47c56388 100644 --- a/apps/dashboard/src/lib/navigation.test.ts +++ b/apps/dashboard/src/lib/navigation.test.ts @@ -68,6 +68,7 @@ describe('route path helpers', () => { expect(experimentPath('prod-baseline', 'demo project')).toBe( '/projects/demo%20project/experiments/prod-baseline', ); + expect(runsHomePath('wtg-ai-prompts')).toBe('/projects/wtg-ai-prompts?tab=runs'); }); it('keeps unscoped paths for legacy single-project routes', () => { diff --git a/apps/dashboard/src/lib/project-display-name.test.ts b/apps/dashboard/src/lib/project-display-name.test.ts new file mode 100644 index 000000000..549d954a9 --- /dev/null +++ b/apps/dashboard/src/lib/project-display-name.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'bun:test'; + +import { resolveProjectDisplayName } from './project-display-name'; + +describe('resolveProjectDisplayName', () => { + it('uses the registry name for project-scoped dashboard chrome', () => { + expect( + resolveProjectDisplayName('wtg-ai-prompts', [ + { + id: 'wtg-ai-prompts', + name: 'WTG.AI.Prompts', + }, + ]), + ).toBe('WTG.AI.Prompts'); + }); + + it('falls back to the URL-safe ID when the registry name is unavailable', () => { + expect(resolveProjectDisplayName('wtg-ai-prompts', [])).toBe('wtg-ai-prompts'); + expect( + resolveProjectDisplayName('wtg-ai-prompts', [{ id: 'wtg-ai-prompts', name: ' ' }]), + ).toBe('wtg-ai-prompts'); + }); +}); diff --git a/apps/dashboard/src/lib/project-display-name.ts b/apps/dashboard/src/lib/project-display-name.ts new file mode 100644 index 000000000..dc80ee5a0 --- /dev/null +++ b/apps/dashboard/src/lib/project-display-name.ts @@ -0,0 +1,21 @@ +/** + * Resolve the human project name shown in Dashboard chrome. + * + * Project-scoped URLs use stable registry IDs, while visible chrome should + * show the registry name from `/api/projects`. Callers pass the project list + * they already fetched and this helper falls back to the ID only when the + * registry name is unavailable. + */ + +export interface ProjectDisplayEntry { + id: string; + name?: string | null; +} + +export function resolveProjectDisplayName( + projectId: string, + projects: readonly ProjectDisplayEntry[] | undefined, +): string { + const name = projects?.find((project) => project.id === projectId)?.name?.trim(); + return name || projectId; +} diff --git a/apps/dashboard/src/routes/projects/$projectId.tsx b/apps/dashboard/src/routes/projects/$projectId.tsx index 04f60e637..a87e05bbd 100644 --- a/apps/dashboard/src/routes/projects/$projectId.tsx +++ b/apps/dashboard/src/routes/projects/$projectId.tsx @@ -10,6 +10,7 @@ import { useState } from 'react'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { AnalyticsTab } from '~/components/AnalyticsTab'; import { ExperimentsTab } from '~/components/ExperimentsTab'; +import { ProjectChromeTitle } from '~/components/ProjectChromeTitle'; import { RunEvalModal } from '~/components/RunEvalModal'; import { RunList } from '~/components/RunList'; import { type RunSourceFilter, RunSourceToolbar } from '~/components/RunSourceToolbar'; @@ -23,6 +24,7 @@ import { useRemoteStatus, useStudioConfig, } from '~/lib/api'; +import { resolveProjectDisplayName } from '~/lib/project-display-name'; import { buildProjectSyncFeedback } from '~/lib/project-sync-status'; import { dedupeSyncedRuns } from '~/lib/run-dedupe'; @@ -49,22 +51,14 @@ function ProjectHomePage() { const { data: config } = useStudioConfig(projectId); const { data: projectData } = useProjectList(); const isReadOnly = config?.read_only === true; - const projectName = - projectData?.projects.find((project) => project.id === projectId)?.name ?? - config?.project_name ?? - projectId; + const projectName = resolveProjectDisplayName(projectId, projectData?.projects); const activeTab: TabId = tabs.some((t) => t.id === tab) ? (tab as TabId) : 'runs'; return (
-
-

{projectName}

- {projectName !== projectId ? ( -

{projectId}

- ) : null} -
+ {!isReadOnly && (