Navigation
Config-driven sidebar powered by a recursive NavNode tree — supports arbitrary nesting, style presets, and a menu block type for embedding nav trees in views.
Overview
The sidebar navigation is fully config-driven. A three-layer resolution system merges platform defaults, tenant overrides, and user overrides into a single NavConfig at render time. The config is expressed as a recursive NavNode tree — a generic menu data structure consumed by the app sidebar, the admin editor, and the menu block type.
Key Concepts
NavNode
A single node in the navigation tree. Nodes are recursive — a group node can contain any mix of child node types to arbitrary depth.
interface NavNode {
id: string
type: NavNodeType
label?: string
icon?: string // lucide icon name
config?: Record<string, unknown>
children?: NavNode[]
hidden?: boolean
position?: number // insertion hint for override merging
}Node Types
| Type | Config fields | Renders |
|---|---|---|
group | defaultOpen?, collapsible? | Collapsible container with children |
link | href | Static navigation link |
entity-type | typeSlug | Link to a data type list page |
entity-query | typeSlug, limit?, sort?, order?, filters? | Filtered data query |
entity | entityId | Link to a specific record |
source | source, maxItems? | Auto-populated dynamic content |
separator | — | Visual divider |
Source Types
source nodes expand at render time using a live data source.
| Source value | What populates it |
|---|---|
entity-types | All data types for the current tenant |
saved-views | All persisted views accessible to the user |
agents | All enabled agents — code-registered (default-agents.ts) and DB-managed (agents table) |
favorites | Records the user has starred |
recent | Recently viewed records |
chats | Recent chat conversations |
The agents source resolves both code-registered agents from features/agents/default-agents.ts and DB-managed tenant agents from the agents table. All items are deduplicated by slug before rendering.
Tenant URL Threading
All dynamic source item components (EntityTypeItems, AgentItems, ChatItems, EntityItems, SavedViewItems) accept a tenantSlug prop and route all generated links through tenantUrl(slug, path) from features/tenant/constants.ts. This preserves the /t/{slug}/ tenant context prefix on navigation. The tenantSlug is threaded from SourceNodeRenderer down to each item component.
If tenantSlug is empty or absent, tenantUrl("", path) returns the path without a prefix (default tenant behavior).
The ViewTabs component similarly accepts and uses a tenantSlug prop so tab navigation, view creation redirects, and view deletion fallback routes all preserve the tenant context.
Sidebar Style Presets
The style field on NavConfig controls the sidebar shell layout.
| Style | shadcn prop | Behavior |
|---|---|---|
sidebar | collapsible="offcanvas" | Default — full width, slides off |
icon-rail | collapsible="icon" | Collapses to 3 rem icon strip; groups show HoverCard flyouts |
floating | variant="floating" | Overlay sidebar with shadow |
inset | variant="inset" | Sidebar inset inside padded container |
NavConfig Schema
interface NavConfig {
style: "sidebar" | "icon-rail" | "floating" | "inset"
nodes: NavNode[]
}
// Partial override stored per tenant/user in nav_configs table
interface NavConfigOverride {
style?: SidebarStyle
nodes?: NavNode[]
}Role-Based Navigation Defaults
The sidebar automatically adapts to the current user's role. Role overrides are code-defined constants in features/navigation/role-defaults.ts and are applied as the second layer in the resolution pipeline — between platform defaults and tenant overrides.
Resolution Chain
Nav config resolution follows a five-layer chain (innermost wins):
1. Platform defaults (PLATFORM_NAV_CONFIG)
2. Role overlay (getRoleNavOverride(user.role) — code-defined, not stored in DB)
3. Tenant override (nav_configs WHERE user_id IS NULL)
4. User override (nav_configs WHERE user_id = current user)
5. Workspace overlay (workspaces.nav_overlay for the active workspace)All five layers use the same NavConfigOverride schema and are merged by resolveNavConfig(). Innermost (workspace) wins on conflict; hidden: true removes a node; position controls insertion order for new nodes.
Built-In Role Overlays
| Role | Overlay behavior |
|---|---|
viewer / guest | Hides power-user links (Graph, Tools, Automations) and the Agents group. |
member / editor | No overlay — platform defaults apply as-is. |
admin / owner | No sidebar overlay. Admin pages are reachable via the user menu in the sidebar footer (avatar → dropdown → "Admin"). |
The auto-injected "Admin" nav group that previously appeared at the top of the sidebar for admins was removed in PR #1065. Admin page reachability now follows the conventional SaaS pattern (footer user menu) rather than polluting the main nav tree.
Role overlays for non-admin roles are returned by getRoleNavOverride(role) from features/navigation/role-defaults.ts. The _isAdmin parameter has been removed from getSidebarNavFallback() in components/app-shell/sidebar-nav-fallback.ts.
Onboarding Wizard Integration
When the first-login onboarding wizard completes, it calls saveNavConfig(rolePreset, "user") to write a user-level override based on the chosen role. This means the user's nav starts from the role preset and can be further customized without affecting other users with the same role.
Platform Defaults
PLATFORM_NAV_CONFIG in features/navigation/defaults.ts encodes seven top-level nodes:
- Navigation (group, non-collapsible) — Dashboard, Feed, Activity, Graph, Documents, Insights
- Separator
- History (group +
chatssource,maxItems: 8, collapsible) - Data (group +
entity-typessource,defaultOpen: true, collapsible) - Views (group +
saved-viewssource, collapsible) - Agents (group +
agentssource, collapsible) - Favorites (group +
favoritessource, collapsible) - Recent (group +
recentsource, collapsible)
How It Works
Resolution
resolveNavConfig(base, overrides) in features/navigation/resolve.ts applies an ordered array of NavConfigOverride objects onto the platform default. getResolvedNavConfig() in server/actions.ts assembles the override array in five-layer order:
- Platform base —
PLATFORM_NAV_CONFIG(hardcoded infeatures/navigation/defaults.ts) - Role overlay —
getRoleNavOverride(user.role)(code-defined infeatures/navigation/role-defaults.ts) - Tenant override — stored in
nav_configswhereuser_id IS NULL - User override — stored in
nav_configswhereuser_id = current user - Workspace overlay —
workspaces.nav_overlayfor the active workspace (applied last so a workspace can pin or hide items on top of user customisations)
Merge rules per node:
- Nodes matched by
id— override mergeslabel,icon,config; children merged recursively hidden: trueon override — removes the node from the resultpositionon a new node (no id match) — inserts at that index, or appends
Style follows last-write-wins: the last override with a non-null style wins.
Cache Layers
Tenant overrides are cached cross-request via unstable_cache (1 week TTL). Mutations call revalidateTag() to invalidate immediately. User overrides are fetched per-request. Both are deduped within a single request via React.cache().
React.cache() → per-request dedup
└─ unstable_cache → cross-request (1-week, tag-invalidated)
└─ adminClient → nav_configs tableRendering
MenuRenderer in features/navigation/components/menu-renderer.tsx accepts a NavNode[] and a collapsed boolean. It dispatches each node to the appropriate renderer:
- group —
GroupNode: renders asSidebarGroupwith optionalCollapsiblewrapper. Incollapsedmode, wraps children in aMenuHoverFlyoutinstead. - source / entity-query —
SourceNodeRenderer: fetches dynamic items and renders them asMenuNodeItems. - all others —
MenuNodeItem: renders as aSidebarMenuItemwith link, icon, and label.
HoverCard Flyouts (Icon Rail)
When collapsed = true, each group node renders as an icon button (SidebarMenuButton). Hovering opens a HoverCard on the right side showing the group label and all child items. This is wired in MenuHoverFlyout.
Admin Editor
NavigationAdmin in features/navigation/components/navigation-admin.tsx:
- Loads the resolved config (platform defaults + tenant override)
- Presents nodes in a
DnDTree— drag the grip handle to reorder top-level nodes - Each node has an inline
NodeEditor— change type, label, icon, and type-specific config fields - Provides a
SidebarStylePickerradio group to change the style preset - Save persists the full node tree as a tenant-scoped override via
saveNavConfig(config, "tenant") - Reset clears the tenant override and reverts to platform defaults
Runtime Migration
Stored configs that use the old { sections: [...] } format are automatically converted to the new { style, nodes } format on read by normalizeStoredOverride() in server/actions.ts. This calls migrateNavConfig() from features/navigation/migrate.ts.
Migration rules:
source: "static"sections →groupnodes withlinkchildren- Collapsible dynamic sections →
groupwrapper node containing asourcechild - Non-collapsible dynamic sections → bare
sourcenode NavItem.href/NavItem.viewId→linknodeconfig.href/config.viewId
No DB migration is required. The conversion is transparent — the next saveNavConfig() call will persist the new format.
Important: normalizeStoredOverride() checks for legacy format before parsing with the new Zod schema. Zod v4's all-optional NavConfigOverrideSchema would silently accept a legacy object as an empty override (stripping the sections key), so the legacy guard must run first.
Menu Block
The menu block type lets any NavNode tree be embedded inside a view or workspace page. Register nodes in the block's config.nodes or data.nodes field. The block renders via MenuRenderer with collapsed = false (always expanded, no sidebar chrome).
// Block config shape
{
type: "menu",
config: {
nodes: NavNode[]
}
}This is useful for custom navigation panels on workspace detail pages or for embedding a filtered data type shortcut list inside a dashboard view.
API Reference
resolveNavConfig(base, overrides)
function resolveNavConfig(
base: NavConfig,
overrides: NavConfigOverride[],
): NavConfigMerges overrides onto base recursively. Exported from features/navigation/resolve.ts. Used by getResolvedNavConfig() server action and the admin editor.
getResolvedNavConfig()
async function getResolvedNavConfig(): Promise<NavConfig>Server action. Returns the fully resolved config for the current user. Cached per-request. Call from layout or page server components.
saveNavConfig(config, scope)
async function saveNavConfig(
config: NavConfigOverride | LegacyNavConfigOverride,
scope: "tenant" | "user",
): Promise<NavConfigRecord>Upserts a nav override for the given scope. Tenant scope requires admin role. Accepts both new and legacy formats.
getTenantNavOverride()
async function getTenantNavOverride(): Promise<NavConfigOverride | null>Returns the raw tenant override without merging with defaults. Used by the admin editor to load the current saved state.
migrateNavConfig(value)
function migrateNavConfig(value: unknown): NavConfigConverts a legacy { sections } config to { style, nodes }. Pass-through for configs already in new format. Exported from features/navigation/migrate.ts.
isLegacyConfig(value)
function isLegacyConfig(value: unknown): booleanReturns true if the value has a sections array and no nodes key.
MenuRenderer
<MenuRenderer
nodes={NavNode[]}
dynamicData={SidebarDynamicData} // optional pre-fetched source data
collapsed={boolean} // true = icon-rail mode
/>Client component. Renders a NavNode tree. Used by the app sidebar and the menu block.
For Agents
Navigation config is managed through the admin tools. There is no dedicated nav tool in the agent ToolSet. To modify navigation programmatically:
- Use
manageView(admin tool) to create views that can be linked from nav nodes - Use
updateAgentConfig(admin tool) if navigation preferences are encoded in agent config - Direct nav mutations require calling the
saveNavConfigserver action, which requires admin permissions
Design Decisions
NavNode tree instead of flat sections. The old NavSection[] model had a fixed two-level hierarchy (section → items) and no way to mix static links with dynamic sources at the same level. The NavNode tree is recursive and type-dispatched — any node can contain any other node type as a child.
Admin link routed through the resolve pipeline, not client-side injection. The previous approach mutated the sidebar node list in a client component (useAdminNavInjection) after the nav config was fetched. This meant the Admin link was invisible to the resolver and therefore invisible to the nav editor — tenants could not hide or move it. The current approach defines ADMIN_OVERRIDE in role-defaults.ts and applies it as a standard NavConfigOverride at layer 2. Tenants can now hide the Admin group with { id: "admin-section", hidden: true } in their tenant override, or rename/move it like any other node.
Five-layer override chain. Adding the role overlay (layer 2) and workspace overlay (layer 5) keeps a single merge function (resolveNavConfig) and a single schema (NavConfigOverride) across all layers. There is no special-casing for role or workspace — they push overrides into the same array and the merge is identical.
Legacy types kept as deprecated exports. NavSection, NavItem, NavSectionSource, NavSectionSchema, NavItemSchema, and LegacyNavConfig are all still exported from types.ts with @deprecated JSDoc tags. This is intentional: existing tenant-stored configs serialize these shapes, and the migration layer needs the types to deserialize them correctly before converting. Deleting the types would break migrateNavConfig().
Runtime migration, not DB migration. Converting every stored nav config in the database would require a migration script with rollback risk. The runtime approach converts lazily on first read and writes the new format on next save. This is safe because the old format is unambiguous (sections present, nodes absent) and the migration is deterministic.
isLegacyConfig runs before Zod parse. Zod v4's all-optional schema would accept { sections: [...] } and return {} (stripping unknown keys). The legacy check must run first to preserve the sections data for migration.
DnD over move buttons. The previous admin UI used up/down arrow buttons to reorder sections. With a potentially deep tree and many nodes, this is O(n) clicks for an O(1) operation. @dnd-kit/sortable provides pointer and keyboard drag-and-drop with an 8px activation threshold to avoid accidental drags.
HoverCard for icon-rail flyouts. When the sidebar is in icon-rail mode, the SidebarGroup collapsible doesn't make sense at 3 rem width. HoverCard is used instead because it handles positioning, focus management, and close-on-click automatically. Delay is set to 200ms open / 150ms close to avoid accidental triggers.
Related Modules
- Block System — menu block type
- View System — saved views appear as nav source items
- Multi-Tenant — nav configs are tenant-scoped;
nav_configstable - Auth & Permissions — tenant-scope saves require
requireAdmin()
Manual Test Script
Prerequisites
- Logged in as admin
- At least one data type and one saved view exist
Happy Path
-
Verify default sections
- Load any page
- Expected: Sidebar shows Navigation group, Data group (with data type items), and History group
-
Check dynamic sources
- Star a record from its detail page
- Expected: Record appears in the Favorites group
-
Verify recent items
- Visit 3-4 different record detail pages
- Expected: Those records appear in the Recent group
-
Test icon-rail mode
- Go to Admin > Navigation, set Style to "Icon Rail" and save
- Expected: Sidebar collapses to icon strip; hovering a group icon opens a HoverCard with its children
-
Test DnD reorder
- Go to Admin > Navigation
- Drag the "Agents" group above "Data"
- Save; reload the page
- Expected: Sidebar shows Agents before Data
-
Tenant override
- In Admin > Navigation, hide the Agents group (set
hidden: truevia node editor) - Expected: Agents group disappears from the sidebar for all members
- In Admin > Navigation, hide the Agents group (set
-
Reset to defaults
- Click Reset in Admin > Navigation
- Expected: Sidebar reverts to platform defaults; previous customizations gone
Edge Cases
- Old stored config: A tenant with a pre-migration
{ sections }config should automatically see the correct sidebar — no manual action needed - Empty group: A group node with all children hidden should not show a blank heading
- Guest role: Data type items are visible but link to read-only views
- Personalization fails: Sidebar falls back gracefully to tenant defaults
Regression Checks
- Sidebar renders on first load without a flash of empty content
- Collapsible groups remember open/closed state across page navigation
-
maxItems: 8on History group caps chat history to 8 items - Tenant-level hidden node is invisible to all members, including admins
- Icon-rail HoverCard closes when navigating away
- DnD reorder persists after page reload
Troubleshooting
| Symptom | Likely Cause | Fix |
|---|---|---|
| Data group is empty | No data types defined for this tenant | Go to Admin > Data Types and create at least one |
| Favorites not updating | Favorite action failed silently | Check browser console for a network error; verify entities.own.update permission |
| Sidebar shows old layout after admin save | Browser has stale cached route | Hard-reload the page; the server cache is invalidated on save |
| Icon-rail flyout doesn't appear | Sidebar style is not set to Icon Rail | Verify style preset in Admin > Navigation |
| Drag handle not visible | Node list is not hovered | The grip handle uses group-hover visibility — hover over the row to reveal it |