Documentation source
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.
```ts
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 — drag-to-reorder via the hover grip handle (`SortableFavoriteItems`); order persists per user + tenant in `user_favorites.sort_order` via `PATCH /api/favorites/reorder`. Each favorite is also a Notion-style page tree (`favorite-tree.tsx`): hovering swaps the type icon for an expand chevron, children (`entities.parent_id`) load lazily from `/api/entities/[id]/descendants?maxDepth=1`, nest up to 5 levels, and expansion state persists per tenant in localStorage |
| `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
```ts
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:
1. **Navigation** (group, non-collapsible) — Dashboard, Feed, Activity, Graph, Documents, Insights
2. Separator
3. **History** (group + `chats` source, `maxItems: 8`, collapsible)
4. **Data** (group + `entity-types` source, `defaultOpen: true`, collapsible)
5. **Views** (group + `saved-views` source, collapsible)
6. **Agents** (group + `agents` source, collapsible)
7. **Favorites** (group + `favorites` source, collapsible)
8. **Recent** (group + `recent` source, 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:
1. **Platform base** — `PLATFORM_NAV_CONFIG` (hardcoded in `features/navigation/defaults.ts`)
2. **Role overlay** — `getRoleNavOverride(user.role)` (code-defined in `features/navigation/role-defaults.ts`)
3. **Tenant override** — stored in `nav_configs` where `user_id IS NULL`
4. **User override** — stored in `nav_configs` where `user_id = current user`
5. **Workspace overlay** — `workspaces.nav_overlay` for 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 merges `label`, `icon`, `config`; children merged recursively
- `hidden: true` on override — removes the node from the result
- `position` on 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.
### Route gating
After overrides resolve, `getResolvedNavConfig()` filters out routes the current context can't use. Three declarative gates live on `AppRouteDefinition` (in `features/navigation/routes.ts`) and are enforced together — a route is hidden if **any** gate fails:
- **`requiresEntityTypes`** — route hidden unless the tenant has all listed entity-type slugs (`getUnavailableRouteIds`). Keeps venture-specific surfaces (e.g. `/insights` → `opportunity`) out of tenants that don't model them.
- **`requiresPermission`** — route hidden unless the current user holds the permission (`getGatedRouteIds`, fed by `getUserPermissions()`). RBAC gate; e.g. `/directory` requires `users.team.read`.
- **`requiresSetting`** — route hidden unless the named boolean tenant setting resolves `{ enabled: true }`. Feature-flag gate; e.g. `/directory` requires the `community` setting (off by default, toggled at Admin → Members). Read via `isCommunityEnabled()`.
All three **fail closed** — if permissions or the flag can't be read, the gated route is hidden rather than leaked. The page that backs a gated route should re-check the same gates server-side (the `/directory` page `notFound()`s when community is off or the caller lacks `users.team.read`) so direct-URL access can't bypass the nav filter.
### 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 table
```
### Rendering
`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 as `SidebarGroup` with optional `Collapsible` wrapper. In `collapsed` mode, wraps children in a `MenuHoverFlyout` instead.
- **source / entity-query** — `SourceNodeRenderer`: fetches dynamic items and renders them as `MenuNodeItem`s.
- **all others** — `MenuNodeItem`: renders as a `SidebarMenuItem` with 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`:
1. Loads the resolved config (platform defaults + tenant override)
2. Presents nodes in a `DnDTree` — drag the grip handle to reorder top-level nodes
3. Each node has an inline `NodeEditor` — change type, label, icon, and type-specific config fields
4. Provides a `SidebarStylePicker` radio group to change the style preset
5. Save persists the full node tree as a tenant-scoped override via `saveNavConfig(config, "tenant")`
6. 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 → `group` nodes with `link` children
- Collapsible dynamic sections → `group` wrapper node containing a `source` child
- Non-collapsible dynamic sections → bare `source` node
- `NavItem.href` / `NavItem.viewId` → `link` node `config.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).
```ts
// 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)`
```ts
function resolveNavConfig(
base: NavConfig,
overrides: NavConfigOverride[],
): NavConfig
```
Merges overrides onto base recursively. Exported from `features/navigation/resolve.ts`. Used by `getResolvedNavConfig()` server action and the admin editor.
### `getResolvedNavConfig()`
```ts
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)`
```ts
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()`
```ts
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)`
```ts
function migrateNavConfig(value: unknown): NavConfig
```
Converts a legacy `{ sections }` config to `{ style, nodes }`. Pass-through for configs already in new format. Exported from `features/navigation/migrate.ts`.
### `isLegacyConfig(value)`
```ts
function isLegacyConfig(value: unknown): boolean
```
Returns `true` if the value has a `sections` array and no `nodes` key.
### `MenuRenderer`
```tsx
<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 `saveNavConfig` server action, which requires admin permissions
## Design Decisions
**Prefetch policy v2.** Primary static nav links (`link` and `entity-type` nodes in the top-level Navigation group) use Next.js default prefetch behavior so the route chunk and RSC payload are preloaded on hover/viewport entry. Unbounded dynamic lists (`saved-views`, `recent`, `chats`, `favorites` source nodes) set `prefetch={false}` — they may resolve to arbitrarily many items and each item's `<Link>` would otherwise trigger a prefetch request per item. This avoids thundering-herd prefetch on long dynamic lists while keeping primary navigation fast.
**Active-state contract.** The active nav item uses `bg-primary/10 text-primary` — a tinted primary background with primary-colored text. This is visually distinct from the hover state (`bg-sidebar-accent`, a neutral tint) with no layout shift (no font-weight change). Prior to PR #2362, sidebar active ≡ hover (both `--sidebar-accent`) — 9 items could appear simultaneously active on the dashboard because the `isActivePath` prefix check matched all parent-route prefixes. The fix uses exact-match route gating for items whose `config.href` is a leaf URL.
**Mobile sheet auto-close.** In mobile breakpoints the sidebar renders as a bottom sheet (shadcn `Sheet`). On navigation, the sheet now closes automatically via a `usePathname` effect that calls `setOpenMobile(false)` on path change. The previous behavior left the sheet open after tapping a link — users had to dismiss it manually.
**Sidebar-state cookie SSR.** The `sidebar_state` cookie (written by shadcn `SidebarProvider` on collapse/expand) is now read server-side in `app/(app)/layout.tsx` and passed as `defaultOpen` to `SidebarProvider`. This eliminates the expand/collapse flash on first page load that occurred when the server always rendered the expanded state and the client immediately corrected it.
**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](/docs/features/block-system) — menu block type
- [View System](/docs/features/view-system) — saved views appear as nav source items
- [Multi-Tenant](/docs/features/multi-tenant) — nav configs are tenant-scoped; `nav_configs` table
- [Auth & Permissions](/docs/features/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
1. **Verify default sections**
- Load any page
- Expected: Sidebar shows Navigation group, Data group (with data type items), and History group
2. **Check dynamic sources**
- Star a record from its detail page
- Expected: Record appears in the Favorites group
3. **Verify recent items**
- Visit 3-4 different record detail pages
- Expected: Those records appear in the Recent group
4. **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
5. **Test DnD reorder**
- Go to Admin > Navigation
- Drag the "Agents" group above "Data"
- Save; reload the page
- Expected: Sidebar shows Agents before Data
6. **Tenant override**
- In Admin > Navigation, hide the Agents group (set `hidden: true` via node editor)
- Expected: Agents group disappears from the sidebar for all members
7. **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: 8` on 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
## `/data` — the Data Atlas hub
`/data` is the platform-level landing page that answers "what kinds of data live in this workspace?" It complements the sidebar (which is a quick-jump menu) by showing the full catalogue at a glance with three viewing options.
### Page composition
```
Hero (Atlas eyebrow + sentence)
KPI tiles (with trend lines)
Insight chips (Growth / Stale / Empty / Composition — structured panel)
Activity ribbon (horizontal per-type sparkline track, last 7 days)
Composition strip (stacked-bar treemap with inline labels on ≥10% segments)
─── sticky toolbar (search · sort · scope chips · view-mode picker) ───
Featured strips (Most active · Where you live)
[Catalogue — switches on view mode]
• Atlas (default): card grid with recent-titles strip + hover arrow
• Index: A-Z dense row table with letter-rail nav
• Constellation: force-directed SVG graph of type-to-type relations
Footer (live refresh, pulsing dot when < 30s old)
```
### View modes
The view picker in the toolbar swaps the catalogue area. URL-driven via `?view=atlas|index|constellation` (shareable links); `localStorage` persists the last-used choice across sessions. The URL takes precedence on first visit.
| Mode | Use when |
|---|---|
| `atlas` | Visual scan of all types, glance-aware (default) |
| `index` | Analyst-density — A-Z table sorted by name, with sticky letter rail |
| `constellation` | Schema explorer — see how types connect via `entity_relations` |
The Constellation view caps at 40 nodes (selection order: edge-count desc, then record-count desc). Beyond 5000 `entity_relations` rows the view shows a "skipped" hint instead of running an expensive query — graduate to an RPC if your workspace passes that scale.
### Privacy gate on card data-strip
Cards show up to 3 recent record titles as gray tokens — but only for types where the current user has authored at least one record (`stats.myCount > 0`). Matches the "Where you live" philosophy: the hub surfaces YOUR data concretely without exposing every member's deal/CIM titles on a shared overview.
### Data sources
| Surface | Server query |
|---|---|
| KPI tiles, ribbon, composition, featured strips, Atlas cards, Index rows | `getEntityTypeStats()` (single-pass aggregation) |
| Constellation graph | `getEntityRelationMap()` (separate query on `entity_relations`) |
| Hero sentence + eyebrow | Server-computed in `app/(app)/data/page.tsx` |
Both queries are `react.cache`-wrapped so multiple consumers in the same request share results.
## 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 |