Skip to content
5 changes: 5 additions & 0 deletions .changeset/thick-hounds-matter.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/agents-manage-ui": minor
---

automatically generate and render breadcrumbs in dashboard with Next.js `/[tenantId]/@breadcrumbs/[...slug]/page.tsx` parallel route
141 changes: 141 additions & 0 deletions agents-manage-ui/src/app/[tenantId]/@breadcrumbs/[...slug]/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
import Link from 'next/link';
import type { FC } from 'react';
import { STATIC_LABELS } from '@/constants/theme';
import { getFullAgentAction } from '@/lib/actions/agent-full';
import { fetchArtifactComponent } from '@/lib/api/artifact-components';
import { fetchCredential } from '@/lib/api/credentials';
import { fetchDataComponent } from '@/lib/api/data-components';
import { fetchExternalAgent } from '@/lib/api/external-agents';
import { fetchProject } from '@/lib/api/projects';
import { fetchMCPTool } from '@/lib/api/tools';
import { fetchNangoProviders } from '@/lib/mcp-tools/nango';
import { getErrorCode, getStatusCodeFromErrorCode } from '@/lib/utils/error-serialization';

type LabelKey = keyof typeof STATIC_LABELS;

type FetcherRecord = Record<LabelKey, (id: string) => Promise<string | undefined>>;

interface BreadcrumbItem {
href: string;
label: string;
}

function getStaticLabel(segment: string) {
return segment in STATIC_LABELS ? STATIC_LABELS[segment as LabelKey] : undefined;
}

const BreadcrumbSlot: FC<PageProps<'/[tenantId]/[...slug]'>> = async ({ params }) => {
const { tenantId, slug } = await params;
const crumbs: BreadcrumbItem[] = [];
let href = `/${tenantId}`;
let projectId = '';

const fetchers: Partial<FetcherRecord> = {
async projects(id) {
projectId = id;
const project = await fetchProject(tenantId, id);
return project.data.name;
},
async agents(id) {
const result = await getFullAgentAction(tenantId, projectId, id);
if (result.success) {
return result.data.name;
}
throw {
message: result.error,
code: result.code,
};
},
async artifacts(id) {
const artifact = await fetchArtifactComponent(tenantId, projectId, id);
return artifact.name;
},
async components(id) {
const component = await fetchDataComponent(tenantId, projectId, id);
return component.name;
},
async credentials(id) {
const credential = await fetchCredential(tenantId, projectId, id);
return credential.name;
},
async 'external-agents'(id) {
const externalAgent = await fetchExternalAgent(tenantId, projectId, id);
return externalAgent.name;
},
async 'mcp-servers'(id) {
const tool = await fetchMCPTool(tenantId, projectId, id);
return tool.name;
},
async providers(id) {
const providers = await fetchNangoProviders();
for (const provider of providers) {
if (encodeURIComponent(provider.name) === id) {
return provider.display_name;
}
}
},
async conversations() {
return 'Conversation Details';
},
};

function addCrumb({ segment, label }: { segment: string; label: string }) {
href += `/${segment}`;
// This route isn't exist so we don't add it to crumbs list
if (href !== `/${tenantId}/projects/${projectId}/traces/conversations`) {
crumbs.push({ label, href });
}
}

for (const [index, segment] of slug.entries()) {
let label: string | undefined;
try {
const prev = slug[index - 1];
// this condition is needed until we remove all `/[segment]/new` routes
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// this condition is needed until we remove all `/[segment]/new` routes

I think this comment is no longer relevant, since we'll keep /new routes, right @sarah-inkeep ?

if (segment === 'new') {
const parentLabel = getStaticLabel(prev);
label = parentLabel ? `New ${parentLabel.replace(/s$/, '')}` : 'New';
} else {
const fetcher = Object.hasOwn(fetchers, prev)
? fetchers[prev as keyof typeof fetchers]
: undefined;
label = fetcher ? await fetcher(segment) : getStaticLabel(segment);
if (!label) {
throw new Error(`Unknown breadcrumb segment "${segment}"`);
}
}
} catch (error) {
const errorCode = getErrorCode(error);
const resolvedStatusCode = getStatusCodeFromErrorCode(errorCode);
label = resolvedStatusCode ? `${resolvedStatusCode} Error` : 'Error';
addCrumb({ segment, label });
break; // stop traversing if error occurs in some segment
}
addCrumb({ segment, label });
}

return crumbs.map(({ label, href }, idx, arr) => {
const isLast = idx === arr.length - 1;
return (
<li
key={href}
aria-current={isLast ? 'page' : undefined}
className={
isLast
? 'font-medium text-foreground'
: 'after:ml-2 after:content-["/"] after:text-muted-foreground/60'
}
>
{isLast ? (
label
) : (
<Link href={href} className="hover:text-foreground">
{label}
</Link>
)}
</li>
);
});
};

export default BreadcrumbSlot;
10 changes: 10 additions & 0 deletions agents-manage-ui/src/app/[tenantId]/@breadcrumbs/default.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import type { FC } from 'react';

// Accessing /[tenantId] triggers:
// No default component was found for a parallel route rendered on this page. Falling back to nearest NotFound boundary.
// Missing slots: @breadcrumbs
const Default: FC = () => {
return null;
};

export default Default;
42 changes: 38 additions & 4 deletions agents-manage-ui/src/app/[tenantId]/layout.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,44 @@
import type { FC } from 'react';
import { HeaderMenus } from '@/components/layout/header-menus';
import { AppSidebarProvider } from '@/components/sidebar-nav/app-sidebar-provider';
import { SidebarInset } from '@/components/ui/sidebar';
import { Separator } from '@/components/ui/separator';
import { SidebarInset, SidebarTrigger } from '@/components/ui/sidebar';
import { cn } from '@/lib/utils';

export default function Layout({ children }: LayoutProps<'/[tenantId]'>) {
const Layout: FC<LayoutProps<'/[tenantId]'>> = ({ children, breadcrumbs }) => {
return (
<AppSidebarProvider>
<SidebarInset>{children}</SidebarInset>
<SidebarInset>
<div className="h-[calc(100vh-16px)] flex flex-col overflow-hidden">
<header className="h-(--header-height) shrink-0 border-b transition-[width,height] ease-linear group-has-data-[collapsible=icon]/sidebar-wrapper:h-(--header-height) bg-muted/20 dark:bg-background rounded-t-[14px] flex items-center gap-1 px-4 lg:gap-2 lg:px-6">
<SidebarTrigger className="-ml-1 text-muted-foreground hover:text-foreground hover:bg-accent dark:text-muted-foreground dark:hover:text-foreground dark:hover:bg-accent/50" />
<Separator orientation="vertical" className="mx-2 data-[orientation=vertical]:h-4" />
<nav aria-label="Breadcrumb">
<ol className="text-sm text-muted-foreground flex items-center gap-2">
{breadcrumbs}
</ol>
</nav>
<Separator
orientation="vertical"
className="mx-2 data-[orientation=vertical]:h-4 ml-auto"
/>
<HeaderMenus />
</header>
<main
id="main-content"
className={cn(
'flex flex-col flex-1 @container',
'overflow-y-auto',
'scrollbar-thin scrollbar-track-transparent',
'scrollbar-thumb-muted-foreground/30 dark:scrollbar-thumb-muted-foreground/50'
)}
>
<div className="flex-1 p-6 [&:has(>.no-container)]:contents">{children}</div>
</main>
</div>
</SidebarInset>
</AppSidebarProvider>
);
}
};

export default Layout;
2 changes: 1 addition & 1 deletion agents-manage-ui/src/app/[tenantId]/page.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { redirectToProject } from '../../lib/utils/project-redirect';
import { redirectToProject } from '@/lib/utils/project-redirect';

async function TenantPage({ params }: PageProps<'/[tenantId]'>) {
const { tenantId } = await params;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
import type { FC } from 'react';
import { BodyTemplate } from '@/components/layout/body-template';
import { Skeleton } from '@/components/ui/skeleton';

export const AgentSkeleton: FC = () => {
const AgentLoading: FC = () => {
return (
<div className="flex p-4">
<div className="flex p-4 no-container">
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
<div className="flex p-4 no-container">
<div className="flex p-4 no-container">

maybe better class name will be no-parent-container ?

<div className="flex flex-col gap-2" style={{ width: 160 }}>
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} style={{ height: 38 }} />
Expand All @@ -21,17 +20,4 @@ export const AgentSkeleton: FC = () => {
);
};

// To avoid have flash of skeleton from `[projectId]/loading.tsx` until `agent` is fetched from `getFullAgentAction` in `page.tsx` file
const AgentLoading: FC = () => {
return (
<BodyTemplate
breadcrumbs={[]}
// Remove inner div from the layout so the p-6 padding doesn’t apply
className="contents"
>
<AgentSkeleton />
</BodyTemplate>
);
};

export default AgentLoading;
Original file line number Diff line number Diff line change
Expand Up @@ -1020,7 +1020,7 @@ export const Agent: FC<AgentProps> = ({
id="agent-panel-group"
direction="horizontal"
autoSaveId="agent-resizable-layout-state"
className="relative bg-muted/20 dark:bg-background flex rounded-b-[14px] overflow-hidden"
className="relative bg-muted/20 dark:bg-background flex rounded-b-[14px] overflow-hidden no-container"
>
<CopilotChat
agentId={agent.id}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,24 +1,33 @@
import { type FC, Suspense } from 'react';
import type { FC } from 'react';
import FullPageError from '@/components/errors/full-page-error';
import { BodyTemplate } from '@/components/layout/body-template';
import { getFullAgentAction } from '@/lib/actions/agent-full';
import { fetchArtifactComponentsAction } from '@/lib/actions/artifact-components';
import { fetchCredentialsAction } from '@/lib/actions/credentials';
import { fetchDataComponentsAction } from '@/lib/actions/data-components';
import { fetchExternalAgentsAction } from '@/lib/actions/external-agents';
import { fetchToolsAction } from '@/lib/actions/tools';
import type { FullAgentDefinition } from '@/lib/types/agent-full';
import { createLookup } from '@/lib/utils';
import { AgentSkeleton } from './loading';
import { Agent } from './page.client';

export const dynamic = 'force-dynamic';

const AgentData: FC<{
agent: FullAgentDefinition;
tenantId: string;
projectId: string;
}> = async ({ agent, tenantId, projectId }) => {
const AgentPage: FC<PageProps<'/[tenantId]/projects/[projectId]/agents/[agentId]'>> = async ({
params,
}) => {
const { agentId, tenantId, projectId } = await params;
const agent = await getFullAgentAction(tenantId, projectId, agentId);

if (!agent.success) {
return (
<FullPageError
errorCode={agent.code}
context="agent"
link={`/${tenantId}/projects/${projectId}/agents`}
linkText="Back to agents"
/>
);
}

const [dataComponents, artifactComponents, credentials, tools, externalAgents] =
await Promise.all([
fetchDataComponentsAction(tenantId, projectId),
Expand Down Expand Up @@ -58,7 +67,7 @@ const AgentData: FC<{

return (
<Agent
agent={agent}
agent={agent.data}
dataComponentLookup={dataComponentLookup}
artifactComponentLookup={artifactComponentLookup}
toolLookup={toolLookup}
Expand All @@ -67,37 +76,4 @@ const AgentData: FC<{
);
};

const AgentPage: FC<PageProps<'/[tenantId]/projects/[projectId]/agents/[agentId]'>> = async ({
params,
}) => {
const { agentId, tenantId, projectId } = await params;
const agent = await getFullAgentAction(tenantId, projectId, agentId);

if (!agent.success) {
return (
<FullPageError
errorCode={agent.code}
context="agent"
link={`/${tenantId}/projects/${projectId}/agents`}
linkText="Back to agents"
/>
);
}

return (
<BodyTemplate
breadcrumbs={[
{ label: 'Agents', href: `/${tenantId}/projects/${projectId}/agents` },
agent.data.name,
]}
// Remove inner div from the layout so the p-6 padding doesn’t apply
className="contents"
>
<Suspense fallback={<AgentSkeleton />}>
<AgentData agent={agent.data} tenantId={tenantId} projectId={projectId} />
</Suspense>
</BodyTemplate>
);
};

export default AgentPage;
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import { AgentList } from '@/components/agents/agents-list';
import { NewAgentDialog } from '@/components/agents/new-agent-item';
import { AgentItem } from '@/components/agents/agent-item';
import { NewAgentDialog, NewAgentItem } from '@/components/agents/new-agent-item';
import FullPageError from '@/components/errors/full-page-error';
import { AgentsIcon } from '@/components/icons/empty-state/agents';
import { BodyTemplate } from '@/components/layout/body-template';
import EmptyState from '@/components/layout/empty-state';
import { PageHeader } from '@/components/layout/page-header';
import { agentDescription } from '@/constants/page-descriptions';
Expand All @@ -14,11 +13,16 @@ export const dynamic = 'force-dynamic';
async function AgentsPage({ params }: PageProps<'/[tenantId]/projects/[projectId]/agents'>) {
const { tenantId, projectId } = await params;
try {
const agents = await fetchAgents(tenantId, projectId);
const content = agents.data.length ? (
const { data } = await fetchAgents(tenantId, projectId);
return data.length ? (
<>
<PageHeader title="Agents" description={agentDescription} />
<AgentList tenantId={tenantId} projectId={projectId} agent={agents.data} />
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3 md:gap-4">
<NewAgentItem tenantId={tenantId} projectId={projectId} />
{data.map((agent) => (
<AgentItem key={agent.id} {...agent} tenantId={tenantId} projectId={projectId} />
))}
</div>
</>
) : (
<EmptyState
Expand All @@ -29,13 +33,6 @@ async function AgentsPage({ params }: PageProps<'/[tenantId]/projects/[projectId
icon={<AgentsIcon />}
/>
);
return (
<BodyTemplate
breadcrumbs={[{ label: 'Agents', href: `/${tenantId}/projects/${projectId}/agents` }]}
>
{content}
</BodyTemplate>
);
} catch (error) {
return <FullPageError errorCode={getErrorCode(error)} context="agents" />;
}
Expand Down
Loading
Loading