feat(web): give Configure sections real routes under /configure (#1378)

Configure sections were local useState, so they could not be linked or bookmarked and always opened on the default section. Each section is now a route (/configure, /configure/models, /configure/workspace-prompt, /configure/proactivity, /configure/automations), mirroring the existing /integrations/[card] pattern. The shell renders from the segment layout so switching sections does not remount the app, and legacy /?view=configure links forward to /configure.

Fixes ENG-1140
This commit is contained in:
MaheshtheDev 2026-07-30 19:27:36 +00:00
parent 33e927417f
commit a051ba0e28
6 changed files with 112 additions and 24 deletions

View file

@ -0,0 +1,17 @@
import { notFound, redirect } from "next/navigation"
import {
DEFAULT_CONFIGURE_SECTION,
isConfigureSection,
} from "@/lib/configure-routes"
export default async function ConfigureSectionPage({
params,
}: {
params: Promise<{ section: string }>
}) {
const { section } = await params
// Default section is canonical at /configure.
if (section === DEFAULT_CONFIGURE_SECTION) redirect("/configure")
if (!isConfigureSection(section)) notFound()
return null
}

View file

@ -0,0 +1,15 @@
import { AppExperience } from "@/components/app-experience"
// Shell lives here so section nav doesn't remount the app.
export default function ConfigureLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<>
<AppExperience />
{children}
</>
)
}

View file

@ -0,0 +1,4 @@
// Shell renders in layout.tsx.
export default function ConfigurePage() {
return null
}

View file

@ -2,7 +2,8 @@
import { cn } from "@lib/utils"
import { Blocks, CalendarClock, Cpu, ScrollText } from "lucide-react"
import { useState } from "react"
import Link from "next/link"
import { usePathname } from "next/navigation"
import CompanyBrainConnections from "@/components/settings/company-brain-connections"
import CompanyBrainModels from "@/components/settings/company-brain-models"
import CompanyBrainProactivity from "@/components/settings/company-brain-proactivity"
@ -11,15 +12,14 @@ import { ProactivenessIcon } from "@/components/settings/proactiveness-icon"
import { WorkspacePrompt } from "@/components/settings/workspace-prompt"
import { ErrorBoundary } from "@/components/error-boundary"
import { useAuth } from "@lib/auth-context"
import {
type ConfigureSection,
configureSectionToPath,
DEFAULT_CONFIGURE_SECTION,
pathToConfigureSection,
} from "@/lib/configure-routes"
import { dmSans125ClassName } from "@/lib/fonts"
type ConfigureSection =
| "company-brain"
| "models"
| "workspace-prompt"
| "proactivity"
| "automations"
const SECTIONS: {
id: ConfigureSection
label: string
@ -27,7 +27,7 @@ const SECTIONS: {
icon: React.ComponentType<{ className?: string }>
}[] = [
{
id: "company-brain",
id: "tools",
label: "Integrations",
description:
"Connect the tools your brain works with. Your account covers your own actions and reads; workspace accounts are a shared fallback.",
@ -65,8 +65,10 @@ const SECTIONS: {
export function ConfigureView() {
const { org } = useAuth()
const [activeSection, setActiveSection] =
useState<ConfigureSection>("company-brain")
const pathname = usePathname()
// Reachable via ?view=configure too, where the path carries no section.
const activeSection =
pathToConfigureSection(pathname) ?? DEFAULT_CONFIGURE_SECTION
const active = SECTIONS.find((section) => section.id === activeSection)
if (!active) return null
@ -90,11 +92,10 @@ export function ConfigureView() {
const isActive = section.id === activeSection
const Icon = section.icon
return (
<button
<Link
key={section.id}
type="button"
href={configureSectionToPath(section.id)}
aria-current={isActive ? "page" : undefined}
onClick={() => setActiveSection(section.id)}
className={cn(
"flex shrink-0 items-center gap-2.5 rounded-[8px] px-3 py-2 text-left text-[13px] font-medium transition-colors",
isActive
@ -109,7 +110,7 @@ export function ConfigureView() {
)}
/>
{section.label}
</button>
</Link>
)
})}
</nav>
@ -135,7 +136,7 @@ export function ConfigureView() {
</p>
}
>
{activeSection === "company-brain" ? (
{activeSection === "tools" ? (
<CompanyBrainConnections />
) : activeSection === "models" ? (
<CompanyBrainModels showHeading={false} />

View file

@ -0,0 +1,37 @@
// Sections under the /configure route.
// "tools" is the "Integrations" section, slugged to avoid clashing with /integrations.
export const CONFIGURE_SECTIONS = [
"tools",
"models",
"workspace-prompt",
"proactivity",
"automations",
] as const
export type ConfigureSection = (typeof CONFIGURE_SECTIONS)[number]
export const DEFAULT_CONFIGURE_SECTION: ConfigureSection = "tools"
export function isConfigureSection(slug: string): slug is ConfigureSection {
return (CONFIGURE_SECTIONS as readonly string[]).includes(slug)
}
export function configureSectionToPath(section: ConfigureSection): string {
return section === DEFAULT_CONFIGURE_SECTION
? "/configure"
: `/configure/${section}`
}
export function pathToConfigureSection(
pathname: string,
): ConfigureSection | null {
const trimmed = pathname.replace(/\/$/, "")
if (trimmed === "/configure") return DEFAULT_CONFIGURE_SECTION
const slug = trimmed.match(/^\/configure\/([^/]+)$/)?.[1]
if (slug && isConfigureSection(slug)) return slug
return null
}
export function isConfigurePath(pathname: string): boolean {
return pathToConfigureSection(pathname) !== null
}

View file

@ -8,6 +8,7 @@ import {
isIntegrationView,
pathToIntegrationView,
} from "@/lib/integration-routes"
import { isConfigurePath } from "@/lib/configure-routes"
import { analytics } from "@/lib/analytics"
import { useCallback, useEffect } from "react"
@ -33,8 +34,11 @@ export function useViewMode() {
const router = useRouter()
const [paramView, setParamView] = useQueryState("view", viewParam)
// On /integrations[/card] the path is the source of truth; elsewhere the ?view param is.
const pathView = pathToIntegrationView(pathname)
// On /integrations[/card] and /configure[/section] the path is the source of truth;
// elsewhere the ?view param is.
const pathView: ViewMode | null =
pathToIntegrationView(pathname) ??
(isConfigurePath(pathname) ? "configure" : null)
const viewMode: ViewMode = pathView ?? paramView
const setViewMode = useCallback(
@ -44,8 +48,12 @@ export function useViewMode() {
router.push(integrationViewToPath(mode))
return
}
// Leaving (or already off) the integrations route for a non-integration view.
if (pathToIntegrationView(pathname)) {
if (mode === "configure") {
router.push("/configure")
return
}
// Leaving (or already off) a path-owned route for a param-owned view.
if (pathToIntegrationView(pathname) || isConfigurePath(pathname)) {
router.push(mode === "dashboard" ? "/" : `/?view=${mode}`)
return
}
@ -57,8 +65,8 @@ export function useViewMode() {
return { viewMode, setViewMode, isInitialized: true }
}
// Forwards legacy /?view=integrations (and sub-views) to the canonical /integrations route,
// preserving any other query params. Call once near the app root.
// Forwards legacy /?view=integrations (and sub-views) and /?view=configure to their
// canonical routes, preserving any other query params. Call once near the app root.
export function useLegacyViewRedirect() {
const pathname = usePathname()
const router = useRouter()
@ -67,10 +75,16 @@ export function useLegacyViewRedirect() {
useEffect(() => {
if (pathname !== "/") return
const view = searchParams.get("view")
if (!view || !isIntegrationView(view)) return
if (!view) return
const target = isIntegrationView(view)
? integrationViewToPath(view)
: view === "configure"
? "/configure"
: null
if (!target) return
const params = new URLSearchParams(searchParams.toString())
params.delete("view")
const qs = params.toString()
router.replace(integrationViewToPath(view) + (qs ? `?${qs}` : ""))
router.replace(target + (qs ? `?${qs}` : ""))
}, [pathname, searchParams, router])
}