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
3 changes: 2 additions & 1 deletion apps/dashboard/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
10 changes: 4 additions & 6 deletions apps/dashboard/src/components/Breadcrumbs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
runPath,
suitePath,
} from '~/lib/navigation';
import { type ProjectDisplayEntry, resolveProjectDisplayName } from '~/lib/project-display-name';

interface BreadcrumbSegment {
label: string;
Expand All @@ -33,7 +34,7 @@ function formatRunLabel(runId: string | undefined): string {

function deriveSegments(
matches: ReturnType<typeof useMatches>,
projectNames: ReadonlyMap<string, string> = new Map(),
projects: readonly ProjectDisplayEntry[] = [],
): BreadcrumbSegment[] {
const segments: BreadcrumbSegment[] = [];

Expand All @@ -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({
Expand Down Expand Up @@ -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;

Expand Down
16 changes: 16 additions & 0 deletions apps/dashboard/src/components/ProjectChromeTitle.test.tsx
Original file line number Diff line number Diff line change
@@ -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(
<ProjectChromeTitle projectId="wtg-ai-prompts" displayName="WTG.AI.Prompts" />,
);

expect(html).toContain('WTG.AI.Prompts');
expect(html).toContain('wtg-ai-prompts');
expect(html.indexOf('WTG.AI.Prompts')).toBeLessThan(html.indexOf('wtg-ai-prompts'));
});
});
23 changes: 23 additions & 0 deletions apps/dashboard/src/components/ProjectChromeTitle.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div>
<h1 className="text-2xl font-semibold text-white">{displayName}</h1>
{displayName !== projectId ? (
<p className="mt-0.5 text-sm text-gray-500">{projectId}</p>
) : null}
</div>
);
}
3 changes: 2 additions & 1 deletion apps/dashboard/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions apps/dashboard/src/lib/navigation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
23 changes: 23 additions & 0 deletions apps/dashboard/src/lib/project-display-name.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
21 changes: 21 additions & 0 deletions apps/dashboard/src/lib/project-display-name.ts
Original file line number Diff line number Diff line change
@@ -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;
}
14 changes: 4 additions & 10 deletions apps/dashboard/src/routes/projects/$projectId.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';

Expand All @@ -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 (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-semibold text-white">{projectName}</h1>
{projectName !== projectId ? (
<p className="mt-0.5 text-sm text-gray-500">{projectId}</p>
) : null}
</div>
<ProjectChromeTitle projectId={projectId} displayName={projectName} />
{!isReadOnly && (
<button
type="button"
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
"lint": "biome check .",
"format": "biome format --write .",
"fix": "biome check --write .",
"test": "bun --filter @agentv/core test && bun --filter @agentv/eval test && bun --filter @agentv/phoenix-adapter test && bun --filter agentv test",
"test": "bun --filter @agentv/core test && bun --filter @agentv/eval test && bun --filter @agentv/phoenix-adapter test && bun --filter agentv test && bun --filter @agentv/dashboard test",
"test:watch": "bun --filter @agentv/core test:watch & bun --filter agentv test:watch",
"agentv": "bun apps/cli/src/cli.ts",
"agentv:buildrun": "bun run build && bun apps/cli/dist/cli.js",
Expand Down
Loading