Sprinter Docs

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

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

TypeConfig fieldsRenders
groupdefaultOpen?, collapsible?Collapsible container with children
linkhrefStatic navigation link
entity-typetypeSlugLink to a data type list page
entity-querytypeSlug, limit?, sort?, order?, filters?Filtered data query
entityentityIdLink to a specific record
sourcesource, maxItems?Auto-populated dynamic content
separatorVisual divider

Source Types

source nodes expand at render time using a live data source.

Source valueWhat populates it
entity-typesAll data types for the current tenant
saved-viewsAll persisted views accessible to the user
agentsAll enabled agents — code-registered (default-agents.ts) and DB-managed (agents table)
favoritesRecords the user has starred
recentRecently viewed records
chatsRecent 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.

The style field on NavConfig controls the sidebar shell layout.

Styleshadcn propBehavior
sidebarcollapsible="offcanvas"Default — full width, slides off
icon-railcollapsible="icon"Collapses to 3 rem icon strip; groups show HoverCard flyouts
floatingvariant="floating"Overlay sidebar with shadow
insetvariant="inset"Sidebar inset inside padded container
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

RoleOverlay behavior
viewer / guestHides power-user links (Graph, Tools, Automations) and the Agents group.
member / editorNo overlay — platform defaults apply as-is.
admin / ownerNo 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 basePLATFORM_NAV_CONFIG (hardcoded in features/navigation/defaults.ts)
  2. Role overlaygetRoleNavOverride(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 overlayworkspaces.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.

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:

  • groupGroupNode: renders as SidebarGroup with optional Collapsible wrapper. In collapsed mode, wraps children in a MenuHoverFlyout instead.
  • source / entity-querySourceNodeRenderer: fetches dynamic items and renders them as MenuNodeItems.
  • all othersMenuNodeItem: 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.viewIdlink 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.

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[],
): NavConfig

Merges 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): 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)

function isLegacyConfig(value: unknown): boolean

Returns true if the value has a sections array and no nodes key.

<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

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.

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

Troubleshooting

SymptomLikely CauseFix
Data group is emptyNo data types defined for this tenantGo to Admin > Data Types and create at least one
Favorites not updatingFavorite action failed silentlyCheck browser console for a network error; verify entities.own.update permission
Sidebar shows old layout after admin saveBrowser has stale cached routeHard-reload the page; the server cache is invalidated on save
Icon-rail flyout doesn't appearSidebar style is not set to Icon RailVerify style preset in Admin > Navigation
Drag handle not visibleNode list is not hoveredThe grip handle uses group-hover visibility — hover over the row to reveal it

On this page