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
34 changes: 30 additions & 4 deletions apps/cli/src/commands/results/serve.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import {
loadProjectRegistry,
removeProject,
syncProjects,
touchProject,
} from '@agentv/core';
import type { Context } from 'hono';
import { Hono } from 'hono';
Expand Down Expand Up @@ -128,14 +129,26 @@ export function loadResults(content: string): EvaluationResult[] {
}

export function resolveDashboardMode(
projectCount: number,
_projectCount: number,
options: { single?: boolean },
): { projectDashboard: boolean } {
if (options.single === true) {
return { projectDashboard: false };
}

return { projectDashboard: projectCount > 0 };
return { projectDashboard: true };
}

function bootstrapCurrentProject(
cwd: string,
options: { single?: boolean },
): { currentProjectId?: string } {
if (options.single === true) return {};
if (!existsSync(path.join(cwd, '.agentv'))) return {};

const entry = addProject(cwd);
touchProject(entry.id);
return { currentProjectId: entry.id };
}

// ── Feedback persistence ─────────────────────────────────────────────────
Expand Down Expand Up @@ -985,13 +998,14 @@ async function handleTargets(c: C, { searchDir, agentvDir }: DataContext) {
function handleConfig(
c: C,
{ agentvDir, searchDir }: DataContext,
options?: { readOnly?: boolean; projectDashboard?: boolean },
options?: { readOnly?: boolean; projectDashboard?: boolean; currentProjectId?: string },
) {
return c.json({
...loadStudioConfig(agentvDir),
read_only: options?.readOnly === true,
project_name: path.basename(searchDir),
project_dashboard: options?.projectDashboard === true,
...(options?.currentProjectId && { current_project_id: options.currentProjectId }),
});
}

Expand Down Expand Up @@ -1057,7 +1071,12 @@ export function createApp(
resultDir: string,
cwd?: string,
sourceFile?: string,
options?: { studioDir?: string; readOnly?: boolean; projectDashboard?: boolean },
options?: {
studioDir?: string;
readOnly?: boolean;
projectDashboard?: boolean;
currentProjectId?: string;
},
): Hono {
const searchDir = cwd ?? resultDir;
const agentvDir = path.join(searchDir, '.agentv');
Expand Down Expand Up @@ -1260,6 +1279,7 @@ export function createApp(
handleConfig(c, defaultCtx, {
readOnly,
projectDashboard: options?.projectDashboard,
currentProjectId: options?.currentProjectId,
}),
);
app.get('/api/remote/status', async (c) => c.json(await getRemoteResultsStatus(searchDir)));
Expand Down Expand Up @@ -1603,6 +1623,8 @@ export const resultsServeCommand = command({
await enforceRequiredVersion(yamlConfig.required_version);
}

const { currentProjectId } = bootstrapCurrentProject(cwd, { single });

// ── Determine dashboard mode ─────────────────────────────────────
const registry = loadProjectRegistry();
const { projectDashboard } = resolveDashboardMode(registry.projects.length, { single });
Expand Down Expand Up @@ -1644,10 +1666,14 @@ export const resultsServeCommand = command({
const app = createApp(results, resultDir, cwd, sourceFile, {
readOnly,
projectDashboard,
currentProjectId,
});

if (projectDashboard) {
console.log(`Project dashboard: ${registry.projects.length} project(s) registered`);
if (currentProjectId) {
console.log(`Default project: ${currentProjectId}`);
}
} else if (results.length > 0 && sourceFile) {
console.log(`Serving ${results.length} result(s) from ${sourceFile}`);
} else {
Expand Down
46 changes: 46 additions & 0 deletions apps/studio/src/lib/navigation.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'bun:test';

import {
initialProjectRedirectStorageKey,
resolveIndexRoute,
resolveInitialProjectRedirect,
} from './navigation';

describe('resolveInitialProjectRedirect', () => {
it('prefers the cwd-backed project on first load when it is registered', () => {
expect(resolveInitialProjectRedirect(['alpha', 'beta'], 'beta')).toBe('beta');
});

it('does not auto-open again after the initial redirect was already used', () => {
expect(resolveInitialProjectRedirect(['alpha', 'beta'], 'beta', true)).toBeUndefined();
});

it('ignores a current project id that is not registered', () => {
expect(resolveInitialProjectRedirect(['alpha'], 'missing')).toBeUndefined();
});
});

describe('initialProjectRedirectStorageKey', () => {
it('uses a stable per-project session storage key', () => {
expect(initialProjectRedirectStorageKey('beta')).toBe(
'agentv.studio.initial-project-redirect:beta',
);
});
});

describe('resolveIndexRoute', () => {
it('uses the legacy single-project home only when project_dashboard is false', () => {
expect(resolveIndexRoute([], false)).toEqual({ kind: 'single-project-home' });
});

it('redirects to the current project when Studio was launched from a registered project', () => {
expect(resolveIndexRoute(['alpha', 'beta'], true, 'beta', 'runs')).toEqual({
kind: 'redirect',
redirectPath: '/projects/beta?tab=runs',
});
});

it('shows the projects dashboard by default even when only one project is registered', () => {
expect(resolveIndexRoute(['alpha'], true)).toEqual({ kind: 'dashboard' });
});
});
21 changes: 19 additions & 2 deletions apps/studio/src/lib/navigation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ export interface IndexRouteDecision {
redirectPath?: string;
}

export function initialProjectRedirectStorageKey(projectId: string): string {
return `agentv.studio.initial-project-redirect:${projectId}`;
}

export function projectHomePath(projectId: string, tab?: StudioTabId): string {
const base = `/projects/${encodeURIComponent(projectId)}`;
return tab ? `${base}?tab=${encodeURIComponent(tab)}` : base;
Expand Down Expand Up @@ -61,19 +65,32 @@ export function experimentsHomePath(projectId?: string): string {
return projectId ? projectHomePath(projectId, 'experiments') : '/?tab=experiments';
}

export function resolveInitialProjectRedirect(
projectIds: string[],
currentProjectId?: string,
alreadyRedirected = false,
): string | undefined {
if (alreadyRedirected) {
return undefined;
}

return currentProjectId && projectIds.includes(currentProjectId) ? currentProjectId : undefined;
}

export function resolveIndexRoute(
projectIds: string[],
projectDashboard: boolean | undefined,
preferredProjectId?: string,
tab?: StudioTabId,
): IndexRouteDecision {
if (projectDashboard === false) {
return { kind: 'single-project-home' };
}

if (projectIds.length === 1) {
if (preferredProjectId && projectIds.includes(preferredProjectId)) {
return {
kind: 'redirect',
redirectPath: projectHomePath(projectIds[0], tab),
redirectPath: projectHomePath(preferredProjectId, tab),
};
}

Expand Down
1 change: 1 addition & 0 deletions apps/studio/src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -254,6 +254,7 @@ export interface StudioConfigResponse {
read_only?: boolean;
project_name?: string;
project_dashboard?: boolean;
current_project_id?: string;
}

export interface RemoteStatusResponse {
Expand Down
47 changes: 42 additions & 5 deletions apps/studio/src/routes/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
/**
* Home route: thin entry layer that either redirects to the only registered
* project, shows the projects dashboard, or falls back to the legacy
* single-project home when Studio is explicitly running in single mode.
* Home route: thin entry layer that either auto-opens the cwd-backed project
* on the first visit, shows the projects dashboard, or falls back to the
* legacy single-project home when Studio is explicitly running in single mode.
*
* Uses URL search param `?tab=` for tab persistence.
*/
Expand All @@ -27,7 +27,12 @@ import {
useRemoteStatus,
useStudioConfig,
} from '~/lib/api';
import { type StudioTabId, resolveIndexRoute } from '~/lib/navigation';
import {
initialProjectRedirectStorageKey,
resolveIndexRoute,
resolveInitialProjectRedirect,
type StudioTabId,
} from '~/lib/navigation';
import type { RunMeta } from '~/lib/types';
type TabId = StudioTabId;

Expand All @@ -50,9 +55,41 @@ function HomePage() {
const { data: projectData, isLoading: projectsLoading } = useProjectList();
const { data: config, isLoading: configLoading } = useStudioConfig();
const projects = projectData?.projects ?? [];
const [preferredProjectId, setPreferredProjectId] = useState<string | null | undefined>(
undefined,
);

useEffect(() => {
if (projectsLoading || configLoading) {
return;
}

const projectId = config?.current_project_id;
if (!projectId) {
setPreferredProjectId(null);
return;
}

const storageKey = initialProjectRedirectStorageKey(projectId);
const alreadyRedirected =
typeof window !== 'undefined' && window.sessionStorage.getItem(storageKey) === '1';
const initialProjectId = resolveInitialProjectRedirect(
projects.map((project) => project.id),
projectId,
alreadyRedirected,
);

if (typeof window !== 'undefined' && initialProjectId) {
window.sessionStorage.setItem(storageKey, '1');
}

setPreferredProjectId(initialProjectId ?? null);
}, [config?.current_project_id, configLoading, projects, projectsLoading]);

const decision = resolveIndexRoute(
projects.map((project) => project.id),
config?.project_dashboard,
preferredProjectId ?? undefined,
tab,
);

Expand All @@ -62,7 +99,7 @@ function HomePage() {
}
}, [decision, navigate]);

if (projectsLoading || configLoading) {
if (projectsLoading || configLoading || preferredProjectId === undefined) {
return <LoadingSkeleton />;
}

Expand Down
Loading