diff --git a/tests/unit/web/project-navigation.test.ts b/tests/unit/web/project-navigation.test.ts new file mode 100644 index 00000000..785a2869 --- /dev/null +++ b/tests/unit/web/project-navigation.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { + DEFAULT_PROJECT_SECTION, + PROJECT_SECTIONS, + isProjectActive, + isSectionActive, + resolveDefaultProjectPath, +} from '../../../web/src/lib/project-sections.js'; + +describe('PROJECT_SECTIONS', () => { + it('contains exactly the expected sections in order', () => { + expect(PROJECT_SECTIONS.map((s) => s.id)).toEqual([ + 'general', + 'harness', + 'work', + 'integrations', + 'agent-configs', + ]); + }); + + it('each section has a non-empty label and path', () => { + for (const section of PROJECT_SECTIONS) { + expect(section.label.length).toBeGreaterThan(0); + expect(section.path.length).toBeGreaterThan(0); + } + }); + + it('has unique ids', () => { + const ids = PROJECT_SECTIONS.map((s) => s.id); + expect(new Set(ids).size).toBe(ids.length); + }); + + it('has unique paths', () => { + const paths = PROJECT_SECTIONS.map((s) => s.path); + expect(new Set(paths).size).toBe(paths.length); + }); +}); + +describe('DEFAULT_PROJECT_SECTION', () => { + it('is "general"', () => { + expect(DEFAULT_PROJECT_SECTION).toBe('general'); + }); + + it('exists in PROJECT_SECTIONS', () => { + const ids = PROJECT_SECTIONS.map((s) => s.id); + expect(ids).toContain(DEFAULT_PROJECT_SECTION); + }); +}); + +describe('section path mapping', () => { + it('maps general section to /general path', () => { + const generalSection = PROJECT_SECTIONS.find((s) => s.id === 'general'); + expect(generalSection?.path).toBe('general'); + }); + + it('maps agent-configs section to /agent-configs path', () => { + const agentConfigsSection = PROJECT_SECTIONS.find((s) => s.id === 'agent-configs'); + expect(agentConfigsSection?.path).toBe('agent-configs'); + }); + + it('maps work section to /work path', () => { + const workSection = PROJECT_SECTIONS.find((s) => s.id === 'work'); + expect(workSection?.path).toBe('work'); + }); + + it('maps integrations section to /integrations path', () => { + const integrationsSection = PROJECT_SECTIONS.find((s) => s.id === 'integrations'); + expect(integrationsSection?.path).toBe('integrations'); + }); +}); + +describe('isProjectActive', () => { + it('detects active project from section path', () => { + expect(isProjectActive('/projects/my-project/general', 'my-project')).toBe(true); + expect(isProjectActive('/projects/my-project/work', 'my-project')).toBe(true); + expect(isProjectActive('/projects/my-project/agent-configs', 'my-project')).toBe(true); + }); + + it('detects active project at root path', () => { + expect(isProjectActive('/projects/my-project', 'my-project')).toBe(true); + }); + + it('does not falsely match other projects', () => { + expect(isProjectActive('/projects/other-project/general', 'my-project')).toBe(false); + expect(isProjectActive('/projects', 'my-project')).toBe(false); + }); +}); + +describe('isSectionActive', () => { + it('returns true for matching section path', () => { + expect(isSectionActive('/projects/proj1/general', 'proj1', 'general')).toBe(true); + expect(isSectionActive('/projects/proj1/work', 'proj1', 'work')).toBe(true); + expect(isSectionActive('/projects/proj1/agent-configs', 'proj1', 'agent-configs')).toBe(true); + }); + + it('returns false for non-matching section', () => { + expect(isSectionActive('/projects/proj1/general', 'proj1', 'work')).toBe(false); + expect(isSectionActive('/projects/proj1/integrations', 'proj1', 'general')).toBe(false); + }); + + it('returns false for different project', () => { + expect(isSectionActive('/projects/proj2/general', 'proj1', 'general')).toBe(false); + }); + + it('returns true for sub-paths of a section', () => { + expect(isSectionActive('/projects/proj1/work/details', 'proj1', 'work')).toBe(true); + }); +}); + +describe('resolveDefaultProjectPath', () => { + it('resolves to /general for any project id', () => { + expect(resolveDefaultProjectPath('abc123')).toBe('/projects/abc123/general'); + expect(resolveDefaultProjectPath('my-project')).toBe('/projects/my-project/general'); + }); + + it('always uses the DEFAULT_PROJECT_SECTION', () => { + const projectId = 'test-proj'; + expect(resolveDefaultProjectPath(projectId)).toBe( + `/projects/${projectId}/${DEFAULT_PROJECT_SECTION}`, + ); + }); +}); diff --git a/web/src/components/layout/sidebar.tsx b/web/src/components/layout/sidebar.tsx index 1954834c..2b89ebcc 100644 --- a/web/src/components/layout/sidebar.tsx +++ b/web/src/components/layout/sidebar.tsx @@ -1,4 +1,5 @@ import { Separator } from '@/components/ui/separator.js'; +import { PROJECT_SECTIONS, isProjectActive, isSectionActive } from '@/lib/project-sections.js'; import { trpc } from '@/lib/trpc.js'; import { cn } from '@/lib/utils.js'; import { useQuery } from '@tanstack/react-query'; @@ -7,6 +8,8 @@ import { Activity, BookOpen, Building, + ChevronDown, + ChevronRight, FolderGit2, KeyRound, LayoutDashboard, @@ -14,6 +17,7 @@ import { Users, Zap, } from 'lucide-react'; +import { useEffect, useState } from 'react'; interface SidebarProps { user: { name: string; email: string; role: string } | undefined; @@ -66,6 +70,69 @@ function NavLink({ ); } +interface ProjectNavItemProps { + project: { id: string; name: string }; + currentPath: string; +} + +function ProjectNavItem({ project, currentPath }: ProjectNavItemProps) { + const activeProject = isProjectActive(currentPath, project.id); + const [isExpanded, setIsExpanded] = useState(activeProject); + + // Sync expansion state when the active project changes due to URL navigation + useEffect(() => { + if (activeProject) { + setIsExpanded(true); + } + }, [activeProject]); + + return ( +
+ + + {isExpanded && ( +
+ {PROJECT_SECTIONS.map((section) => { + const sectionActive = isSectionActive(currentPath, project.id, section.path); + return ( + + {section.label} + + ); + })} +
+ )} +
+ ); +} + export function Sidebar({ user }: SidebarProps) { const routerState = useRouterState(); const currentPath = routerState.location.pathname; @@ -89,16 +156,10 @@ export function Sidebar({ user }: SidebarProps) {
Projects
-
+
{projects && projects.length > 0 ? ( projects.map((project) => ( - + )) ) : ( - navigate({ to: '/projects/$projectId', params: { projectId: project.id } }) + navigate({ + to: '/projects/$projectId/general', + params: { projectId: project.id }, + }) } > {project.name} diff --git a/web/src/lib/project-sections.ts b/web/src/lib/project-sections.ts new file mode 100644 index 00000000..f5366dba --- /dev/null +++ b/web/src/lib/project-sections.ts @@ -0,0 +1,55 @@ +export type ProjectSection = 'general' | 'harness' | 'work' | 'integrations' | 'agent-configs'; + +export type ProjectSectionRoute = + | '/projects/$projectId/general' + | '/projects/$projectId/harness' + | '/projects/$projectId/work' + | '/projects/$projectId/integrations' + | '/projects/$projectId/agent-configs'; + +export const PROJECT_SECTIONS: { + id: ProjectSection; + label: string; + path: string; + route: ProjectSectionRoute; +}[] = [ + { id: 'general', label: 'General', path: 'general', route: '/projects/$projectId/general' }, + { id: 'harness', label: 'Harness', path: 'harness', route: '/projects/$projectId/harness' }, + { id: 'work', label: 'Work', path: 'work', route: '/projects/$projectId/work' }, + { + id: 'integrations', + label: 'Integrations', + path: 'integrations', + route: '/projects/$projectId/integrations', + }, + { + id: 'agent-configs', + label: 'Agent Configs', + path: 'agent-configs', + route: '/projects/$projectId/agent-configs', + }, +]; + +export const DEFAULT_PROJECT_SECTION: ProjectSection = 'general'; + +/** + * Returns true if the given pathname is within the given project. + */ +export function isProjectActive(pathname: string, projectId: string): boolean { + return pathname.startsWith(`/projects/${projectId}/`) || pathname === `/projects/${projectId}`; +} + +/** + * Returns true if the given pathname matches a specific section of a project. + */ +export function isSectionActive(pathname: string, projectId: string, sectionPath: string): boolean { + const fullPath = `/projects/${projectId}/${sectionPath}`; + return pathname === fullPath || pathname.startsWith(`${fullPath}/`); +} + +/** + * Resolves the default section URL for a given project. + */ +export function resolveDefaultProjectPath(projectId: string): string { + return `/projects/${projectId}/${DEFAULT_PROJECT_SECTION}`; +} diff --git a/web/src/routes/projects/$projectId.agent-configs.tsx b/web/src/routes/projects/$projectId.agent-configs.tsx new file mode 100644 index 00000000..33555a60 --- /dev/null +++ b/web/src/routes/projects/$projectId.agent-configs.tsx @@ -0,0 +1,14 @@ +import { ProjectAgentConfigs } from '@/components/projects/project-agent-configs.js'; +import { createRoute } from '@tanstack/react-router'; +import { projectDetailRoute } from './$projectId.js'; + +function ProjectAgentConfigsPage() { + const { projectId } = projectAgentConfigsRoute.useParams(); + return ; +} + +export const projectAgentConfigsRoute = createRoute({ + getParentRoute: () => projectDetailRoute, + path: '/agent-configs', + component: ProjectAgentConfigsPage, +}); diff --git a/web/src/routes/projects/$projectId.general.tsx b/web/src/routes/projects/$projectId.general.tsx new file mode 100644 index 00000000..eb95efae --- /dev/null +++ b/web/src/routes/projects/$projectId.general.tsx @@ -0,0 +1,26 @@ +import { ProjectGeneralForm } from '@/components/projects/project-general-form.js'; +import { trpc } from '@/lib/trpc.js'; +import { useQuery } from '@tanstack/react-query'; +import { createRoute } from '@tanstack/react-router'; +import { projectDetailRoute } from './$projectId.js'; + +function ProjectGeneralPage() { + const { projectId } = projectGeneralRoute.useParams(); + const projectQuery = useQuery(trpc.projects.getById.queryOptions({ id: projectId })); + + if (projectQuery.isLoading) { + return
Loading...
; + } + + if (projectQuery.isError || !projectQuery.data) { + return
Project not found
; + } + + return ; +} + +export const projectGeneralRoute = createRoute({ + getParentRoute: () => projectDetailRoute, + path: '/general', + component: ProjectGeneralPage, +}); diff --git a/web/src/routes/projects/$projectId.harness.tsx b/web/src/routes/projects/$projectId.harness.tsx new file mode 100644 index 00000000..9a86e4eb --- /dev/null +++ b/web/src/routes/projects/$projectId.harness.tsx @@ -0,0 +1,26 @@ +import { ProjectHarnessForm } from '@/components/projects/project-harness-form.js'; +import { trpc } from '@/lib/trpc.js'; +import { useQuery } from '@tanstack/react-query'; +import { createRoute } from '@tanstack/react-router'; +import { projectDetailRoute } from './$projectId.js'; + +function ProjectHarnessPage() { + const { projectId } = projectHarnessRoute.useParams(); + const projectQuery = useQuery(trpc.projects.getById.queryOptions({ id: projectId })); + + if (projectQuery.isLoading) { + return
Loading...
; + } + + if (projectQuery.isError || !projectQuery.data) { + return
Project not found
; + } + + return ; +} + +export const projectHarnessRoute = createRoute({ + getParentRoute: () => projectDetailRoute, + path: '/harness', + component: ProjectHarnessPage, +}); diff --git a/web/src/routes/projects/$projectId.integrations.tsx b/web/src/routes/projects/$projectId.integrations.tsx new file mode 100644 index 00000000..91f82611 --- /dev/null +++ b/web/src/routes/projects/$projectId.integrations.tsx @@ -0,0 +1,14 @@ +import { IntegrationForm } from '@/components/projects/integration-form.js'; +import { createRoute } from '@tanstack/react-router'; +import { projectDetailRoute } from './$projectId.js'; + +function ProjectIntegrationsPage() { + const { projectId } = projectIntegrationsRoute.useParams(); + return ; +} + +export const projectIntegrationsRoute = createRoute({ + getParentRoute: () => projectDetailRoute, + path: '/integrations', + component: ProjectIntegrationsPage, +}); diff --git a/web/src/routes/projects/$projectId.tsx b/web/src/routes/projects/$projectId.tsx index 4f55c3d8..5aea411a 100644 --- a/web/src/routes/projects/$projectId.tsx +++ b/web/src/routes/projects/$projectId.tsx @@ -1,36 +1,13 @@ -import { IntegrationForm } from '@/components/projects/integration-form.js'; -import { ProjectAgentConfigs } from '@/components/projects/project-agent-configs.js'; -import { ProjectGeneralForm } from '@/components/projects/project-general-form.js'; -import { ProjectHarnessForm } from '@/components/projects/project-harness-form.js'; -import { ProjectWorkTable } from '@/components/projects/project-work-table.js'; -import { ProjectWorkDurationChart } from '@/components/runs/project-work-duration-chart.js'; -import { WorkItemCostChart } from '@/components/runs/work-item-cost-chart.js'; import { trpc } from '@/lib/trpc.js'; -import { cn, formatCost } from '@/lib/utils.js'; import { useQuery } from '@tanstack/react-query'; -import { Link, createRoute } from '@tanstack/react-router'; +import { Link, Outlet, createRoute, redirect } from '@tanstack/react-router'; import { ArrowLeft } from 'lucide-react'; -import { useState } from 'react'; import { rootRoute } from '../__root.js'; -type Tab = 'general' | 'harness' | 'work' | 'integrations' | 'agent-configs'; - -const WORK_PAGE_SIZE = 50; - -function ProjectDetailPage() { +function ProjectShellPage() { const { projectId } = projectDetailRoute.useParams(); - const [activeTab, setActiveTab] = useState('general'); - const [workOffset, setWorkOffset] = useState(0); const projectQuery = useQuery(trpc.projects.getById.queryOptions({ id: projectId })); - const workQuery = useQuery({ - ...trpc.prs.listUnified.queryOptions({ projectId }), - enabled: activeTab === 'work', - }); - const workStatsQuery = useQuery({ - ...trpc.prs.workStats.queryOptions({ projectId }), - enabled: activeTab === 'work', - }); if (projectQuery.isLoading) { return
Loading project...
; @@ -42,14 +19,6 @@ function ProjectDetailPage() { const project = projectQuery.data; - const tabs: { id: Tab; label: string }[] = [ - { id: 'general', label: 'General' }, - { id: 'harness', label: 'Harness' }, - { id: 'work', label: 'Work' }, - { id: 'integrations', label: 'Integrations' }, - { id: 'agent-configs', label: 'Agent Configs' }, - ]; - return (
@@ -64,97 +33,7 @@ function ProjectDetailPage() {

{project.name}

-
- -
- - {activeTab === 'general' && } - - {activeTab === 'harness' && } - - {activeTab === 'work' && ( -
-
-

Work

- {workQuery.data && ( - {workQuery.data.length} total - )} -
- - {workStatsQuery.data && workStatsQuery.data.length > 0 && ( - <> -
- ({ ...r, id: String(i) }))} - /> - -
-
- - - {workStatsQuery.data.length >= 500 ? '500+' : workStatsQuery.data.length} - {' '} - {workStatsQuery.data.length >= 500 - ? 'latest runs (showing most recent 500)' - : 'total runs'} - - - - {formatCost( - workStatsQuery.data - .reduce( - (sum, r) => sum + (r.costUsd != null ? Number.parseFloat(r.costUsd) : 0), - 0, - ) - .toFixed(4), - )} - {' '} - total cost - -
- - )} - - {workQuery.isLoading && ( -
Loading work items...
- )} - - {workQuery.isError && ( -
- Failed to load work: {workQuery.error.message} -
- )} - - {workQuery.data && ( - - )} -
- )} - - {activeTab === 'integrations' && } - {activeTab === 'agent-configs' && } +
); } @@ -162,5 +41,15 @@ function ProjectDetailPage() { export const projectDetailRoute = createRoute({ getParentRoute: () => rootRoute, path: '/projects/$projectId', - component: ProjectDetailPage, + component: ProjectShellPage, + beforeLoad: ({ location, params }) => { + // If navigating exactly to /projects/$projectId, redirect to /general subsection + const path = location.pathname; + if (path.match(/^\/projects\/[^/]+\/?$/)) { + throw redirect({ + to: '/projects/$projectId/general', + params: { projectId: params.projectId }, + }); + } + }, }); diff --git a/web/src/routes/projects/$projectId.work.tsx b/web/src/routes/projects/$projectId.work.tsx new file mode 100644 index 00000000..f722d25f --- /dev/null +++ b/web/src/routes/projects/$projectId.work.tsx @@ -0,0 +1,90 @@ +import { ProjectWorkTable } from '@/components/projects/project-work-table.js'; +import { ProjectWorkDurationChart } from '@/components/runs/project-work-duration-chart.js'; +import { WorkItemCostChart } from '@/components/runs/work-item-cost-chart.js'; +import { trpc } from '@/lib/trpc.js'; +import { formatCost } from '@/lib/utils.js'; +import { useQuery } from '@tanstack/react-query'; +import { createRoute } from '@tanstack/react-router'; +import { useState } from 'react'; +import { projectDetailRoute } from './$projectId.js'; + +const WORK_PAGE_SIZE = 50; + +function ProjectWorkPage() { + const { projectId } = projectWorkRoute.useParams(); + const [workOffset, setWorkOffset] = useState(0); + + const workQuery = useQuery(trpc.prs.listUnified.queryOptions({ projectId })); + const workStatsQuery = useQuery(trpc.prs.workStats.queryOptions({ projectId })); + + return ( +
+
+

Work

+ {workQuery.data && ( + {workQuery.data.length} total + )} +
+ + {workStatsQuery.data && workStatsQuery.data.length > 0 && ( + <> +
+ ({ ...r, id: String(i) }))} + /> + +
+
+ + + {workStatsQuery.data.length >= 500 ? '500+' : workStatsQuery.data.length} + {' '} + {workStatsQuery.data.length >= 500 + ? 'latest runs (showing most recent 500)' + : 'total runs'} + + + + {formatCost( + workStatsQuery.data + .reduce( + (sum, r) => sum + (r.costUsd != null ? Number.parseFloat(r.costUsd) : 0), + 0, + ) + .toFixed(4), + )} + {' '} + total cost + +
+ + )} + + {workQuery.isLoading && ( +
Loading work items...
+ )} + + {workQuery.isError && ( +
+ Failed to load work: {workQuery.error.message} +
+ )} + + {workQuery.data && ( + + )} +
+ ); +} + +export const projectWorkRoute = createRoute({ + getParentRoute: () => projectDetailRoute, + path: '/work', + component: ProjectWorkPage, +}); diff --git a/web/src/routes/prs/$projectId.$prNumber.tsx b/web/src/routes/prs/$projectId.$prNumber.tsx index c9199b92..9e329332 100644 --- a/web/src/routes/prs/$projectId.$prNumber.tsx +++ b/web/src/routes/prs/$projectId.$prNumber.tsx @@ -33,12 +33,12 @@ function PRRunsPage() {
- Project + Work /

PR Runs

diff --git a/web/src/routes/route-tree.ts b/web/src/routes/route-tree.ts index c98077ed..93b697f1 100644 --- a/web/src/routes/route-tree.ts +++ b/web/src/routes/route-tree.ts @@ -5,7 +5,12 @@ import { globalRunsRoute } from './global/runs.js'; import { globalWebhookLogsRoute } from './global/webhook-logs.js'; import { indexRoute } from './index.js'; import { loginRoute } from './login.js'; +import { projectAgentConfigsRoute } from './projects/$projectId.agent-configs.js'; +import { projectGeneralRoute } from './projects/$projectId.general.js'; +import { projectHarnessRoute } from './projects/$projectId.harness.js'; +import { projectIntegrationsRoute } from './projects/$projectId.integrations.js'; import { projectDetailRoute } from './projects/$projectId.js'; +import { projectWorkRoute } from './projects/$projectId.work.js'; import { projectsIndexRoute } from './projects/index.js'; import { prRunsRoute } from './prs/$projectId.$prNumber.js'; import { runDetailRoute } from './runs/$runId.js'; @@ -19,7 +24,13 @@ export const routeTree = rootRoute.addChildren([ indexRoute, runDetailRoute, projectsIndexRoute, - projectDetailRoute, + projectDetailRoute.addChildren([ + projectGeneralRoute, + projectHarnessRoute, + projectWorkRoute, + projectIntegrationsRoute, + projectAgentConfigsRoute, + ]), settingsGeneralRoute, settingsCredentialsRoute, settingsUsersRoute, diff --git a/web/src/routes/work-items/$projectId.$workItemId.tsx b/web/src/routes/work-items/$projectId.$workItemId.tsx index 62ed5e42..daaf7bab 100644 --- a/web/src/routes/work-items/$projectId.$workItemId.tsx +++ b/web/src/routes/work-items/$projectId.$workItemId.tsx @@ -32,12 +32,12 @@ function WorkItemRunsPage() {
- Project + Work /

Work Item Runs