From edd38972271520a19a175bd85f0718946408fcf7 Mon Sep 17 00:00:00 2001 From: Christopher Tso Date: Wed, 3 Jun 2026 10:48:06 +0200 Subject: [PATCH 1/2] fix(dashboard): support configurable app branding --- apps/cli/src/commands/results/serve.ts | 4 +- .../cli/src/commands/results/studio-config.ts | 76 ++++++++++++------- apps/cli/test/commands/results/serve.test.ts | 8 +- .../commands/results/studio-config.test.ts | 73 ++++++++++++++++-- apps/dashboard/index.html | 2 +- apps/dashboard/src/components/BrandName.tsx | 13 ++++ apps/dashboard/src/components/Layout.tsx | 8 +- apps/dashboard/src/components/Sidebar.tsx | 75 +++++++----------- apps/dashboard/src/lib/api.ts | 1 + apps/dashboard/src/lib/types.ts | 1 + apps/dashboard/src/routes/settings.tsx | 10 ++- apps/dashboard/src/styles/globals.css | 5 ++ .../src/content/docs/docs/tools/dashboard.mdx | 11 +++ 13 files changed, 200 insertions(+), 87 deletions(-) create mode 100644 apps/dashboard/src/components/BrandName.tsx diff --git a/apps/cli/src/commands/results/serve.ts b/apps/cli/src/commands/results/serve.ts index 40258b2ac..26689d23f 100644 --- a/apps/cli/src/commands/results/serve.ts +++ b/apps/cli/src/commands/results/serve.ts @@ -1002,8 +1002,10 @@ function handleConfig( { agentvDir, searchDir }: DataContext, options?: { readOnly?: boolean; projectDashboard?: boolean; currentProjectId?: string }, ) { + const config = loadStudioConfig(agentvDir); return c.json({ - ...loadStudioConfig(agentvDir), + threshold: config.threshold, + app_name: config.appName, read_only: options?.readOnly === true, project_name: path.basename(searchDir), project_dashboard: options?.projectDashboard === true, diff --git a/apps/cli/src/commands/results/studio-config.ts b/apps/cli/src/commands/results/studio-config.ts index 3e0870214..1afc223e5 100644 --- a/apps/cli/src/commands/results/studio-config.ts +++ b/apps/cli/src/commands/results/studio-config.ts @@ -1,37 +1,39 @@ /** * Dashboard configuration loader. * - * Reads dashboard-specific settings from the `dashboard:` section of - * `.agentv/config.yaml`, falling back to the legacy `studio:` section for - * compatibility. Preserves all other fields (required_version, - * eval_patterns, execution, etc.) when saving. - * - * Location: `.agentv/config.yaml` + * Reads dashboard-specific settings from the `dashboard:` section of config.yaml. + * Project-local `.agentv/config.yaml` takes precedence over the global + * `${AGENTV_HOME:-~/.agentv}/config.yaml`, with legacy `studio:` and root-level + * threshold keys still accepted for compatibility. Saving writes only to the + * project-local file and preserves unrelated fields. * * config.yaml format: * required_version: ">=4.2.0" * dashboard: + * app_name: agent v # displayed in the Dashboard shell * threshold: 0.8 # score >= this value is considered "pass" * * Backward compat: reads `studio.threshold`, `studio.pass_threshold`, and * root-level `pass_threshold` as fallbacks. On save, always writes `threshold` * under `dashboard:`. * - * If no config.yaml exists, defaults are used. + * If no config.yaml exists in either location, defaults are used. */ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import path from 'node:path'; -import { DEFAULT_THRESHOLD, parseYamlValue } from '@agentv/core'; +import { DEFAULT_THRESHOLD, getAgentvConfigDir, parseYamlValue } from '@agentv/core'; import { stringify as stringifyYaml } from 'yaml'; export interface StudioConfig { threshold: number; + appName: string; } const DEFAULTS: StudioConfig = { threshold: DEFAULT_THRESHOLD, + appName: 'agent v', }; /** @@ -43,33 +45,42 @@ const DEFAULTS: StudioConfig = { * Clamps `threshold` to [0, 1]. */ export function loadStudioConfig(agentvDir: string): StudioConfig { - const configPath = path.join(agentvDir, 'config.yaml'); - - if (!existsSync(configPath)) { - return { ...DEFAULTS }; - } - - const raw = readFileSync(configPath, 'utf-8'); - const parsed = parseYamlValue(raw); - - if (!parsed || typeof parsed !== 'object') { - return { ...DEFAULTS }; - } + const localConfigPath = path.join(agentvDir, 'config.yaml'); + const globalConfigPath = path.join(getAgentvConfigDir(), 'config.yaml'); + const localConfig = loadParsedConfig(localConfigPath); + const globalConfig = + path.resolve(globalConfigPath) === path.resolve(localConfigPath) + ? undefined + : loadParsedConfig(globalConfigPath); - // Prefer dashboard config, then legacy studio config, then root-level pass_threshold. - const config = parsed as Record; const threshold = [ - readThreshold(config.dashboard), - readThreshold(config.studio), - typeof config.pass_threshold === 'number' ? config.pass_threshold : undefined, + readThreshold(localConfig?.dashboard), + readThreshold(localConfig?.studio), + typeof localConfig?.pass_threshold === 'number' ? localConfig.pass_threshold : undefined, + readThreshold(globalConfig?.dashboard), + readThreshold(globalConfig?.studio), + typeof globalConfig?.pass_threshold === 'number' ? globalConfig.pass_threshold : undefined, DEFAULTS.threshold, ].find((value) => value !== undefined) as number; return { threshold: Math.min(1, Math.max(0, threshold)), + appName: + readAppName(localConfig?.dashboard) ?? + readAppName(globalConfig?.dashboard) ?? + DEFAULTS.appName, }; } +function loadParsedConfig(configPath: string): Record | undefined { + if (!existsSync(configPath)) return undefined; + + const raw = readFileSync(configPath, 'utf-8'); + const parsed = parseYamlValue(raw); + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined; + return parsed as Record; +} + function readThreshold(section: unknown): number | undefined { if (!section || typeof section !== 'object' || Array.isArray(section)) return undefined; const values = section as Record; @@ -78,6 +89,14 @@ function readThreshold(section: unknown): number | undefined { return undefined; } +function readAppName(section: unknown): string | undefined { + if (!section || typeof section !== 'object' || Array.isArray(section)) return undefined; + const value = (section as Record).app_name; + if (typeof value !== 'string') return undefined; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : undefined; +} + /** * Save dashboard config to `config.yaml` in the given `.agentv/` directory. * Merges into the existing file, preserving all non-dashboard fields @@ -123,7 +142,12 @@ export function saveStudioConfig(agentvDir: string, config: StudioConfig): void const { studio: _legacyStudio, ...withoutStudio } = existing; existing = { ...withoutStudio, - dashboard: { ...studioRest, ...dashboardRest, ...config }, + dashboard: { + ...studioRest, + ...dashboardRest, + threshold: config.threshold, + app_name: config.appName, + }, }; const yamlStr = stringifyYaml(existing); diff --git a/apps/cli/test/commands/results/serve.test.ts b/apps/cli/test/commands/results/serve.test.ts index 8d2e9f3e5..98015dbda 100644 --- a/apps/cli/test/commands/results/serve.test.ts +++ b/apps/cli/test/commands/results/serve.test.ts @@ -212,7 +212,7 @@ describe('resolveDashboardMode', () => { const MOCK_STUDIO_HTML = ` -AgentV Dashboard +agent v
`; @@ -262,7 +262,7 @@ describe('serve app', () => { const res = await app.request('/'); expect(res.status).toBe(200); const html = await res.text(); - expect(html).toContain('AgentV Dashboard'); + expect(html).toContain('agent v'); expect(html).toContain('
'); }); }); @@ -455,7 +455,7 @@ describe('serve app', () => { const res = await app.request('/'); expect(res.status).toBe(200); const html = await res.text(); - expect(html).toContain('AgentV Dashboard'); + expect(html).toContain('agent v'); }); it('serves feedback API with empty results', async () => { @@ -1069,7 +1069,7 @@ describe('serve app', () => { const res = await app.request('/runs/some-run'); expect(res.status).toBe(200); const html = await res.text(); - expect(html).toContain('AgentV Dashboard'); + expect(html).toContain('agent v'); }); it('returns 404 JSON for unknown API routes', async () => { diff --git a/apps/cli/test/commands/results/studio-config.test.ts b/apps/cli/test/commands/results/studio-config.test.ts index cb9ab2e83..70bb714a0 100644 --- a/apps/cli/test/commands/results/studio-config.test.ts +++ b/apps/cli/test/commands/results/studio-config.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it } from 'bun:test'; -import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import path from 'node:path'; @@ -10,18 +10,27 @@ import { loadStudioConfig, saveStudioConfig } from '../../../src/commands/result describe('loadStudioConfig', () => { let tempDir: string; + let previousAgentvHome: string | undefined; beforeEach(() => { tempDir = mkdtempSync(path.join(tmpdir(), 'studio-config-')); + previousAgentvHome = process.env.AGENTV_HOME; + process.env.AGENTV_HOME = path.join(tempDir, 'home'); }); afterEach(() => { + if (previousAgentvHome === undefined) { + process.env.AGENTV_HOME = undefined; + } else { + process.env.AGENTV_HOME = previousAgentvHome; + } rmSync(tempDir, { recursive: true, force: true }); }); it('returns defaults when no config.yaml exists', () => { const config = loadStudioConfig(tempDir); expect(config.threshold).toBe(DEFAULT_THRESHOLD); + expect(config.appName).toBe('agent v'); }); it.each([ @@ -53,6 +62,48 @@ describe('loadStudioConfig', () => { expect(config.threshold).toBe(0.9); }); + it('reads dashboard.app_name for white labelling', () => { + writeFileSync(path.join(tempDir, 'config.yaml'), 'dashboard:\n app_name: ai evals\n'); + expect(loadStudioConfig(tempDir).appName).toBe('ai evals'); + }); + + it('ignores blank dashboard.app_name', () => { + writeFileSync(path.join(tempDir, 'config.yaml'), 'dashboard:\n app_name: " "\n'); + expect(loadStudioConfig(tempDir).appName).toBe('agent v'); + }); + + it('falls back to global config.yaml for dashboard settings', () => { + const homeDir = process.env.AGENTV_HOME; + if (!homeDir) throw new Error('AGENTV_HOME test setup failed'); + mkdirSync(homeDir, { recursive: true }); + writeFileSync( + path.join(homeDir, 'config.yaml'), + 'dashboard:\n app_name: ai evals\n threshold: 0.6\n', + ); + + const config = loadStudioConfig(tempDir); + expect(config.appName).toBe('ai evals'); + expect(config.threshold).toBe(0.6); + }); + + it('prefers local config.yaml over global config.yaml', () => { + const homeDir = process.env.AGENTV_HOME; + if (!homeDir) throw new Error('AGENTV_HOME test setup failed'); + mkdirSync(homeDir, { recursive: true }); + writeFileSync( + path.join(homeDir, 'config.yaml'), + 'dashboard:\n app_name: ai evals\n threshold: 0.6\n', + ); + writeFileSync( + path.join(tempDir, 'config.yaml'), + 'dashboard:\n app_name: local evals\n threshold: 0.9\n', + ); + + const config = loadStudioConfig(tempDir); + expect(config.appName).toBe('local evals'); + expect(config.threshold).toBe(0.9); + }); + it('falls back to legacy studio section when dashboard has no threshold', () => { writeFileSync( path.join(tempDir, 'config.yaml'), @@ -94,12 +145,20 @@ describe('loadStudioConfig', () => { describe('saveStudioConfig', () => { let tempDir: string; + let previousAgentvHome: string | undefined; beforeEach(() => { tempDir = mkdtempSync(path.join(tmpdir(), 'studio-config-')); + previousAgentvHome = process.env.AGENTV_HOME; + process.env.AGENTV_HOME = path.join(tempDir, 'home'); }); afterEach(() => { + if (previousAgentvHome === undefined) { + process.env.AGENTV_HOME = undefined; + } else { + process.env.AGENTV_HOME = previousAgentvHome; + } rmSync(tempDir, { recursive: true, force: true }); }); @@ -108,13 +167,15 @@ describe('saveStudioConfig', () => { path.join(tempDir, 'config.yaml'), 'required_version: ">=4.2.0"\neval_patterns:\n - "**/*.eval.yaml"\n', ); - saveStudioConfig(tempDir, { threshold: 0.9 }); + saveStudioConfig(tempDir, { threshold: 0.9, appName: 'ai evals' }); const raw = readFileSync(path.join(tempDir, 'config.yaml'), 'utf-8'); const parsed = parseYaml(raw) as Record; expect(parsed.required_version).toBe('>=4.2.0'); expect(parsed.eval_patterns).toEqual(['**/*.eval.yaml']); expect((parsed.dashboard as Record).threshold).toBe(0.9); + expect((parsed.dashboard as Record).app_name).toBe('ai evals'); + expect((parsed.dashboard as Record).appName).toBeUndefined(); }); it('writes canonical dashboard.threshold and removes legacy threshold fields on save', () => { @@ -122,7 +183,7 @@ describe('saveStudioConfig', () => { path.join(tempDir, 'config.yaml'), 'required_version: ">=4.2.0"\npass_threshold: 0.8\ndashboard:\n pass_threshold: 0.6\nstudio:\n theme: dark\n pass_threshold: 0.5\n', ); - saveStudioConfig(tempDir, { threshold: 0.7 }); + saveStudioConfig(tempDir, { threshold: 0.7, appName: 'agent v' }); const raw = readFileSync(path.join(tempDir, 'config.yaml'), 'utf-8'); const parsed = parseYaml(raw) as Record; @@ -133,10 +194,12 @@ describe('saveStudioConfig', () => { expect(dashboard.theme).toBe('dark'); expect(dashboard.pass_threshold).toBeUndefined(); expect(dashboard.threshold).toBe(0.7); + expect(dashboard.app_name).toBe('agent v'); + expect(dashboard.appName).toBeUndefined(); }); it('creates config.yaml when it does not exist', () => { - saveStudioConfig(tempDir, { threshold: 0.6 }); + saveStudioConfig(tempDir, { threshold: 0.6, appName: 'agent v' }); const raw = readFileSync(path.join(tempDir, 'config.yaml'), 'utf-8'); const parsed = parseYaml(raw) as Record; @@ -145,7 +208,7 @@ describe('saveStudioConfig', () => { it('creates directory if it does not exist', () => { const nestedDir = path.join(tempDir, 'nested', '.agentv'); - saveStudioConfig(nestedDir, { threshold: 0.5 }); + saveStudioConfig(nestedDir, { threshold: 0.5, appName: 'agent v' }); const raw = readFileSync(path.join(nestedDir, 'config.yaml'), 'utf-8'); const parsed = parseYaml(raw) as Record; diff --git a/apps/dashboard/index.html b/apps/dashboard/index.html index 05777a7f1..d76a60d82 100644 --- a/apps/dashboard/index.html +++ b/apps/dashboard/index.html @@ -3,7 +3,7 @@ - AgentV Dashboard + agent v
diff --git a/apps/dashboard/src/components/BrandName.tsx b/apps/dashboard/src/components/BrandName.tsx new file mode 100644 index 000000000..b0148bdb6 --- /dev/null +++ b/apps/dashboard/src/components/BrandName.tsx @@ -0,0 +1,13 @@ +import { DEFAULT_APP_NAME } from '~/lib/api'; + +export function BrandName({ appName }: { appName: string }) { + if (appName !== DEFAULT_APP_NAME) { + return {appName}; + } + + return ( + + agent v + + ); +} diff --git a/apps/dashboard/src/components/Layout.tsx b/apps/dashboard/src/components/Layout.tsx index a6e9fe15c..aa208b901 100644 --- a/apps/dashboard/src/components/Layout.tsx +++ b/apps/dashboard/src/components/Layout.tsx @@ -11,8 +11,10 @@ import { Outlet } from '@tanstack/react-router'; +import { DEFAULT_APP_NAME, useStudioConfig } from '~/lib/api'; import { SidebarProvider, useSidebarContext } from '~/lib/sidebar-context'; +import { BrandName } from './BrandName'; import { Breadcrumbs } from './Breadcrumbs'; import { Sidebar } from './Sidebar'; @@ -26,6 +28,8 @@ export function Layout() { function LayoutInner() { const { toggle } = useSidebarContext(); + const { data: config } = useStudioConfig(); + const appName = config?.app_name ?? DEFAULT_APP_NAME; return (
@@ -59,7 +63,9 @@ function LayoutInner() { - AgentV Dashboard + + + diff --git a/apps/dashboard/src/components/Sidebar.tsx b/apps/dashboard/src/components/Sidebar.tsx index 860dc23f7..382fdf69b 100644 --- a/apps/dashboard/src/components/Sidebar.tsx +++ b/apps/dashboard/src/components/Sidebar.tsx @@ -18,6 +18,7 @@ import { useQuery } from '@tanstack/react-query'; import { Link, useLocation, useMatchRoute } from '@tanstack/react-router'; import { + DEFAULT_APP_NAME, isPassing, projectCategorySuitesOptions, projectExperimentsOptions, @@ -35,6 +36,8 @@ import { import { formatRunLabel, timeAgo } from '~/lib/run-label'; import { useSidebarContext } from '~/lib/sidebar-context'; +import { BrandName } from './BrandName'; + /** Responsive