From 322f55a8a181c8887320ecea4ce5406e47ddc25f Mon Sep 17 00:00:00 2001 From: Christopher Date: Tue, 26 May 2026 18:53:49 +1000 Subject: [PATCH] feat(studio): default to projects dashboard --- apps/cli/src/commands/results/serve.ts | 34 ++++++++++++++++--- apps/studio/src/lib/navigation.test.ts | 46 +++++++++++++++++++++++++ apps/studio/src/lib/navigation.ts | 21 ++++++++++-- apps/studio/src/lib/types.ts | 1 + apps/studio/src/routes/index.tsx | 47 +++++++++++++++++++++++--- 5 files changed, 138 insertions(+), 11 deletions(-) create mode 100644 apps/studio/src/lib/navigation.test.ts diff --git a/apps/cli/src/commands/results/serve.ts b/apps/cli/src/commands/results/serve.ts index 1cbac71f..34de9b08 100644 --- a/apps/cli/src/commands/results/serve.ts +++ b/apps/cli/src/commands/results/serve.ts @@ -46,6 +46,7 @@ import { loadProjectRegistry, removeProject, syncProjects, + touchProject, } from '@agentv/core'; import type { Context } from 'hono'; import { Hono } from 'hono'; @@ -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 ───────────────────────────────────────────────── @@ -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 }), }); } @@ -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'); @@ -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))); @@ -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 }); @@ -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 { diff --git a/apps/studio/src/lib/navigation.test.ts b/apps/studio/src/lib/navigation.test.ts new file mode 100644 index 00000000..8563d1e7 --- /dev/null +++ b/apps/studio/src/lib/navigation.test.ts @@ -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' }); + }); +}); diff --git a/apps/studio/src/lib/navigation.ts b/apps/studio/src/lib/navigation.ts index 9c224388..c3ddc01b 100644 --- a/apps/studio/src/lib/navigation.ts +++ b/apps/studio/src/lib/navigation.ts @@ -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; @@ -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), }; } diff --git a/apps/studio/src/lib/types.ts b/apps/studio/src/lib/types.ts index 998daef3..a1000ed6 100644 --- a/apps/studio/src/lib/types.ts +++ b/apps/studio/src/lib/types.ts @@ -254,6 +254,7 @@ export interface StudioConfigResponse { read_only?: boolean; project_name?: string; project_dashboard?: boolean; + current_project_id?: string; } export interface RemoteStatusResponse { diff --git a/apps/studio/src/routes/index.tsx b/apps/studio/src/routes/index.tsx index 921889c6..e0781939 100644 --- a/apps/studio/src/routes/index.tsx +++ b/apps/studio/src/routes/index.tsx @@ -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. */ @@ -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; @@ -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( + 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, ); @@ -62,7 +99,7 @@ function HomePage() { } }, [decision, navigate]); - if (projectsLoading || configLoading) { + if (projectsLoading || configLoading || preferredProjectId === undefined) { return ; }