Skip to content

Commit 17a4743

Browse files
authored
fix(dashboard): support white-label app branding
* fix(dashboard): support configurable app branding * fix(dashboard): tighten default brand spacing
1 parent abb0f0b commit 17a4743

13 files changed

Lines changed: 200 additions & 87 deletions

File tree

apps/cli/src/commands/results/serve.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1002,8 +1002,10 @@ function handleConfig(
10021002
{ agentvDir, searchDir }: DataContext,
10031003
options?: { readOnly?: boolean; projectDashboard?: boolean; currentProjectId?: string },
10041004
) {
1005+
const config = loadStudioConfig(agentvDir);
10051006
return c.json({
1006-
...loadStudioConfig(agentvDir),
1007+
threshold: config.threshold,
1008+
app_name: config.appName,
10071009
read_only: options?.readOnly === true,
10081010
project_name: path.basename(searchDir),
10091011
project_dashboard: options?.projectDashboard === true,

apps/cli/src/commands/results/studio-config.ts

Lines changed: 50 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,39 @@
11
/**
22
* Dashboard configuration loader.
33
*
4-
* Reads dashboard-specific settings from the `dashboard:` section of
5-
* `.agentv/config.yaml`, falling back to the legacy `studio:` section for
6-
* compatibility. Preserves all other fields (required_version,
7-
* eval_patterns, execution, etc.) when saving.
8-
*
9-
* Location: `.agentv/config.yaml`
4+
* Reads dashboard-specific settings from the `dashboard:` section of config.yaml.
5+
* Project-local `.agentv/config.yaml` takes precedence over the global
6+
* `${AGENTV_HOME:-~/.agentv}/config.yaml`, with legacy `studio:` and root-level
7+
* threshold keys still accepted for compatibility. Saving writes only to the
8+
* project-local file and preserves unrelated fields.
109
*
1110
* config.yaml format:
1211
* required_version: ">=4.2.0"
1312
* dashboard:
13+
* app_name: agentv # displayed in the Dashboard shell
1414
* threshold: 0.8 # score >= this value is considered "pass"
1515
*
1616
* Backward compat: reads `studio.threshold`, `studio.pass_threshold`, and
1717
* root-level `pass_threshold` as fallbacks. On save, always writes `threshold`
1818
* under `dashboard:`.
1919
*
20-
* If no config.yaml exists, defaults are used.
20+
* If no config.yaml exists in either location, defaults are used.
2121
*/
2222

2323
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
2424
import path from 'node:path';
2525

26-
import { DEFAULT_THRESHOLD, parseYamlValue } from '@agentv/core';
26+
import { DEFAULT_THRESHOLD, getAgentvConfigDir, parseYamlValue } from '@agentv/core';
2727
import { stringify as stringifyYaml } from 'yaml';
2828

2929
export interface StudioConfig {
3030
threshold: number;
31+
appName: string;
3132
}
3233

3334
const DEFAULTS: StudioConfig = {
3435
threshold: DEFAULT_THRESHOLD,
36+
appName: 'agentv',
3537
};
3638

3739
/**
@@ -43,33 +45,42 @@ const DEFAULTS: StudioConfig = {
4345
* Clamps `threshold` to [0, 1].
4446
*/
4547
export function loadStudioConfig(agentvDir: string): StudioConfig {
46-
const configPath = path.join(agentvDir, 'config.yaml');
47-
48-
if (!existsSync(configPath)) {
49-
return { ...DEFAULTS };
50-
}
51-
52-
const raw = readFileSync(configPath, 'utf-8');
53-
const parsed = parseYamlValue(raw);
54-
55-
if (!parsed || typeof parsed !== 'object') {
56-
return { ...DEFAULTS };
57-
}
48+
const localConfigPath = path.join(agentvDir, 'config.yaml');
49+
const globalConfigPath = path.join(getAgentvConfigDir(), 'config.yaml');
50+
const localConfig = loadParsedConfig(localConfigPath);
51+
const globalConfig =
52+
path.resolve(globalConfigPath) === path.resolve(localConfigPath)
53+
? undefined
54+
: loadParsedConfig(globalConfigPath);
5855

59-
// Prefer dashboard config, then legacy studio config, then root-level pass_threshold.
60-
const config = parsed as Record<string, unknown>;
6156
const threshold = [
62-
readThreshold(config.dashboard),
63-
readThreshold(config.studio),
64-
typeof config.pass_threshold === 'number' ? config.pass_threshold : undefined,
57+
readThreshold(localConfig?.dashboard),
58+
readThreshold(localConfig?.studio),
59+
typeof localConfig?.pass_threshold === 'number' ? localConfig.pass_threshold : undefined,
60+
readThreshold(globalConfig?.dashboard),
61+
readThreshold(globalConfig?.studio),
62+
typeof globalConfig?.pass_threshold === 'number' ? globalConfig.pass_threshold : undefined,
6563
DEFAULTS.threshold,
6664
].find((value) => value !== undefined) as number;
6765

6866
return {
6967
threshold: Math.min(1, Math.max(0, threshold)),
68+
appName:
69+
readAppName(localConfig?.dashboard) ??
70+
readAppName(globalConfig?.dashboard) ??
71+
DEFAULTS.appName,
7072
};
7173
}
7274

75+
function loadParsedConfig(configPath: string): Record<string, unknown> | undefined {
76+
if (!existsSync(configPath)) return undefined;
77+
78+
const raw = readFileSync(configPath, 'utf-8');
79+
const parsed = parseYamlValue(raw);
80+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return undefined;
81+
return parsed as Record<string, unknown>;
82+
}
83+
7384
function readThreshold(section: unknown): number | undefined {
7485
if (!section || typeof section !== 'object' || Array.isArray(section)) return undefined;
7586
const values = section as Record<string, unknown>;
@@ -78,6 +89,14 @@ function readThreshold(section: unknown): number | undefined {
7889
return undefined;
7990
}
8091

92+
function readAppName(section: unknown): string | undefined {
93+
if (!section || typeof section !== 'object' || Array.isArray(section)) return undefined;
94+
const value = (section as Record<string, unknown>).app_name;
95+
if (typeof value !== 'string') return undefined;
96+
const trimmed = value.trim();
97+
return trimmed.length > 0 ? trimmed : undefined;
98+
}
99+
81100
/**
82101
* Save dashboard config to `config.yaml` in the given `.agentv/` directory.
83102
* Merges into the existing file, preserving all non-dashboard fields
@@ -123,7 +142,12 @@ export function saveStudioConfig(agentvDir: string, config: StudioConfig): void
123142
const { studio: _legacyStudio, ...withoutStudio } = existing;
124143
existing = {
125144
...withoutStudio,
126-
dashboard: { ...studioRest, ...dashboardRest, ...config },
145+
dashboard: {
146+
...studioRest,
147+
...dashboardRest,
148+
threshold: config.threshold,
149+
app_name: config.appName,
150+
},
127151
};
128152

129153
const yamlStr = stringifyYaml(existing);

apps/cli/test/commands/results/serve.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -212,7 +212,7 @@ describe('resolveDashboardMode', () => {
212212

213213
const MOCK_STUDIO_HTML = `<!doctype html>
214214
<html lang="en" class="dark">
215-
<head><title>AgentV Dashboard</title></head>
215+
<head><title>agentv</title></head>
216216
<body class="bg-gray-950 text-gray-100"><div id="root"></div></body>
217217
</html>`;
218218

@@ -262,7 +262,7 @@ describe('serve app', () => {
262262
const res = await app.request('/');
263263
expect(res.status).toBe(200);
264264
const html = await res.text();
265-
expect(html).toContain('AgentV Dashboard');
265+
expect(html).toContain('agentv');
266266
expect(html).toContain('<div id="root">');
267267
});
268268
});
@@ -455,7 +455,7 @@ describe('serve app', () => {
455455
const res = await app.request('/');
456456
expect(res.status).toBe(200);
457457
const html = await res.text();
458-
expect(html).toContain('AgentV Dashboard');
458+
expect(html).toContain('agentv');
459459
});
460460

461461
it('serves feedback API with empty results', async () => {
@@ -1069,7 +1069,7 @@ describe('serve app', () => {
10691069
const res = await app.request('/runs/some-run');
10701070
expect(res.status).toBe(200);
10711071
const html = await res.text();
1072-
expect(html).toContain('AgentV Dashboard');
1072+
expect(html).toContain('agentv');
10731073
});
10741074

10751075
it('returns 404 JSON for unknown API routes', async () => {

apps/cli/test/commands/results/studio-config.test.ts

Lines changed: 68 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { afterEach, beforeEach, describe, expect, it } from 'bun:test';
2-
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
2+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
33
import { tmpdir } from 'node:os';
44
import path from 'node:path';
55

@@ -10,18 +10,27 @@ import { loadStudioConfig, saveStudioConfig } from '../../../src/commands/result
1010

1111
describe('loadStudioConfig', () => {
1212
let tempDir: string;
13+
let previousAgentvHome: string | undefined;
1314

1415
beforeEach(() => {
1516
tempDir = mkdtempSync(path.join(tmpdir(), 'studio-config-'));
17+
previousAgentvHome = process.env.AGENTV_HOME;
18+
process.env.AGENTV_HOME = path.join(tempDir, 'home');
1619
});
1720

1821
afterEach(() => {
22+
if (previousAgentvHome === undefined) {
23+
process.env.AGENTV_HOME = undefined;
24+
} else {
25+
process.env.AGENTV_HOME = previousAgentvHome;
26+
}
1927
rmSync(tempDir, { recursive: true, force: true });
2028
});
2129

2230
it('returns defaults when no config.yaml exists', () => {
2331
const config = loadStudioConfig(tempDir);
2432
expect(config.threshold).toBe(DEFAULT_THRESHOLD);
33+
expect(config.appName).toBe('agentv');
2534
});
2635

2736
it.each([
@@ -53,6 +62,48 @@ describe('loadStudioConfig', () => {
5362
expect(config.threshold).toBe(0.9);
5463
});
5564

65+
it('reads dashboard.app_name for white labelling', () => {
66+
writeFileSync(path.join(tempDir, 'config.yaml'), 'dashboard:\n app_name: ai evals\n');
67+
expect(loadStudioConfig(tempDir).appName).toBe('ai evals');
68+
});
69+
70+
it('ignores blank dashboard.app_name', () => {
71+
writeFileSync(path.join(tempDir, 'config.yaml'), 'dashboard:\n app_name: " "\n');
72+
expect(loadStudioConfig(tempDir).appName).toBe('agentv');
73+
});
74+
75+
it('falls back to global config.yaml for dashboard settings', () => {
76+
const homeDir = process.env.AGENTV_HOME;
77+
if (!homeDir) throw new Error('AGENTV_HOME test setup failed');
78+
mkdirSync(homeDir, { recursive: true });
79+
writeFileSync(
80+
path.join(homeDir, 'config.yaml'),
81+
'dashboard:\n app_name: ai evals\n threshold: 0.6\n',
82+
);
83+
84+
const config = loadStudioConfig(tempDir);
85+
expect(config.appName).toBe('ai evals');
86+
expect(config.threshold).toBe(0.6);
87+
});
88+
89+
it('prefers local config.yaml over global config.yaml', () => {
90+
const homeDir = process.env.AGENTV_HOME;
91+
if (!homeDir) throw new Error('AGENTV_HOME test setup failed');
92+
mkdirSync(homeDir, { recursive: true });
93+
writeFileSync(
94+
path.join(homeDir, 'config.yaml'),
95+
'dashboard:\n app_name: ai evals\n threshold: 0.6\n',
96+
);
97+
writeFileSync(
98+
path.join(tempDir, 'config.yaml'),
99+
'dashboard:\n app_name: local evals\n threshold: 0.9\n',
100+
);
101+
102+
const config = loadStudioConfig(tempDir);
103+
expect(config.appName).toBe('local evals');
104+
expect(config.threshold).toBe(0.9);
105+
});
106+
56107
it('falls back to legacy studio section when dashboard has no threshold', () => {
57108
writeFileSync(
58109
path.join(tempDir, 'config.yaml'),
@@ -94,12 +145,20 @@ describe('loadStudioConfig', () => {
94145

95146
describe('saveStudioConfig', () => {
96147
let tempDir: string;
148+
let previousAgentvHome: string | undefined;
97149

98150
beforeEach(() => {
99151
tempDir = mkdtempSync(path.join(tmpdir(), 'studio-config-'));
152+
previousAgentvHome = process.env.AGENTV_HOME;
153+
process.env.AGENTV_HOME = path.join(tempDir, 'home');
100154
});
101155

102156
afterEach(() => {
157+
if (previousAgentvHome === undefined) {
158+
process.env.AGENTV_HOME = undefined;
159+
} else {
160+
process.env.AGENTV_HOME = previousAgentvHome;
161+
}
103162
rmSync(tempDir, { recursive: true, force: true });
104163
});
105164

@@ -108,21 +167,23 @@ describe('saveStudioConfig', () => {
108167
path.join(tempDir, 'config.yaml'),
109168
'required_version: ">=4.2.0"\neval_patterns:\n - "**/*.eval.yaml"\n',
110169
);
111-
saveStudioConfig(tempDir, { threshold: 0.9 });
170+
saveStudioConfig(tempDir, { threshold: 0.9, appName: 'ai evals' });
112171

113172
const raw = readFileSync(path.join(tempDir, 'config.yaml'), 'utf-8');
114173
const parsed = parseYaml(raw) as Record<string, unknown>;
115174
expect(parsed.required_version).toBe('>=4.2.0');
116175
expect(parsed.eval_patterns).toEqual(['**/*.eval.yaml']);
117176
expect((parsed.dashboard as Record<string, unknown>).threshold).toBe(0.9);
177+
expect((parsed.dashboard as Record<string, unknown>).app_name).toBe('ai evals');
178+
expect((parsed.dashboard as Record<string, unknown>).appName).toBeUndefined();
118179
});
119180

120181
it('writes canonical dashboard.threshold and removes legacy threshold fields on save', () => {
121182
writeFileSync(
122183
path.join(tempDir, 'config.yaml'),
123184
'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',
124185
);
125-
saveStudioConfig(tempDir, { threshold: 0.7 });
186+
saveStudioConfig(tempDir, { threshold: 0.7, appName: 'agentv' });
126187

127188
const raw = readFileSync(path.join(tempDir, 'config.yaml'), 'utf-8');
128189
const parsed = parseYaml(raw) as Record<string, unknown>;
@@ -133,10 +194,12 @@ describe('saveStudioConfig', () => {
133194
expect(dashboard.theme).toBe('dark');
134195
expect(dashboard.pass_threshold).toBeUndefined();
135196
expect(dashboard.threshold).toBe(0.7);
197+
expect(dashboard.app_name).toBe('agentv');
198+
expect(dashboard.appName).toBeUndefined();
136199
});
137200

138201
it('creates config.yaml when it does not exist', () => {
139-
saveStudioConfig(tempDir, { threshold: 0.6 });
202+
saveStudioConfig(tempDir, { threshold: 0.6, appName: 'agentv' });
140203

141204
const raw = readFileSync(path.join(tempDir, 'config.yaml'), 'utf-8');
142205
const parsed = parseYaml(raw) as Record<string, unknown>;
@@ -145,7 +208,7 @@ describe('saveStudioConfig', () => {
145208

146209
it('creates directory if it does not exist', () => {
147210
const nestedDir = path.join(tempDir, 'nested', '.agentv');
148-
saveStudioConfig(nestedDir, { threshold: 0.5 });
211+
saveStudioConfig(nestedDir, { threshold: 0.5, appName: 'agentv' });
149212

150213
const raw = readFileSync(path.join(nestedDir, 'config.yaml'), 'utf-8');
151214
const parsed = parseYaml(raw) as Record<string, unknown>;

apps/dashboard/index.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
<head>
44
<meta charset="UTF-8" />
55
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
6-
<title>AgentV Dashboard</title>
6+
<title>agentv</title>
77
</head>
88
<body class="bg-gray-950 text-gray-100">
99
<div id="root"></div>
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
import { DEFAULT_APP_NAME } from '~/lib/api';
2+
3+
export function BrandName({ appName }: { appName: string }) {
4+
if (appName !== DEFAULT_APP_NAME) {
5+
return <span className="av-brand-name">{appName}</span>;
6+
}
7+
8+
return (
9+
<span className="av-brand-name">
10+
agent<span className="text-cyan-400">v</span>
11+
</span>
12+
);
13+
}

apps/dashboard/src/components/Layout.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,8 +11,10 @@
1111

1212
import { Outlet } from '@tanstack/react-router';
1313

14+
import { DEFAULT_APP_NAME, useStudioConfig } from '~/lib/api';
1415
import { SidebarProvider, useSidebarContext } from '~/lib/sidebar-context';
1516

17+
import { BrandName } from './BrandName';
1618
import { Breadcrumbs } from './Breadcrumbs';
1719
import { Sidebar } from './Sidebar';
1820

@@ -26,6 +28,8 @@ export function Layout() {
2628

2729
function LayoutInner() {
2830
const { toggle } = useSidebarContext();
31+
const { data: config } = useStudioConfig();
32+
const appName = config?.app_name ?? DEFAULT_APP_NAME;
2933

3034
return (
3135
<div className="flex h-screen overflow-hidden">
@@ -59,7 +63,9 @@ function LayoutInner() {
5963
<line x1="3" y1="18" x2="21" y2="18" />
6064
</svg>
6165
</button>
62-
<span className="text-sm font-semibold text-white">AgentV Dashboard</span>
66+
<span className="text-sm font-semibold text-white">
67+
<BrandName appName={appName} />
68+
</span>
6369
</header>
6470

6571
<Breadcrumbs />

0 commit comments

Comments
 (0)