diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8af35bca..d47af808 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -28,6 +28,7 @@ import { NewAgent } from "./pages/NewAgent"; import { AuthPage } from "./pages/Auth"; import { BoardClaimPage } from "./pages/BoardClaim"; import { InviteLandingPage } from "./pages/InviteLanding"; +import { NotFoundPage } from "./pages/NotFound"; import { queryKeys } from "./lib/queryKeys"; import { useCompany } from "./context/CompanyContext"; import { useDialog } from "./context/DialogContext"; @@ -141,6 +142,7 @@ function boardRoutes() { } /> } /> } /> + } /> ); } @@ -240,6 +242,7 @@ export function App() { }> {boardRoutes()} + } /> diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 9c272b64..3a58e614 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState, type UIEvent } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState, type UIEvent } from "react"; import { useQuery } from "@tanstack/react-query"; import { BookOpen, Moon, Sun } from "lucide-react"; import { Outlet, useLocation, useNavigate, useParams } from "@/lib/router"; @@ -24,13 +24,20 @@ import { useCompanyPageMemory } from "../hooks/useCompanyPageMemory"; import { healthApi } from "../api/health"; import { queryKeys } from "../lib/queryKeys"; import { cn } from "../lib/utils"; +import { NotFoundPage } from "../pages/NotFound"; import { Button } from "@/components/ui/button"; export function Layout() { const { sidebarOpen, setSidebarOpen, toggleSidebar, isMobile } = useSidebar(); const { openNewIssue, openOnboarding } = useDialog(); const { togglePanelVisible } = usePanel(); - const { companies, loading: companiesLoading, selectedCompanyId, setSelectedCompanyId } = useCompany(); + const { + companies, + loading: companiesLoading, + selectedCompany, + selectedCompanyId, + setSelectedCompanyId, + } = useCompany(); const { theme, toggleTheme } = useTheme(); const { companyPrefix } = useParams<{ companyPrefix: string }>(); const navigate = useNavigate(); @@ -39,6 +46,13 @@ export function Layout() { const lastMainScrollTop = useRef(0); const [mobileNavVisible, setMobileNavVisible] = useState(true); const nextTheme = theme === "dark" ? "light" : "dark"; + const matchedCompany = useMemo(() => { + if (!companyPrefix) return null; + const requestedPrefix = companyPrefix.toUpperCase(); + return companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix) ?? null; + }, [companies, companyPrefix]); + const hasUnknownCompanyPrefix = + Boolean(companyPrefix) && !companiesLoading && companies.length > 0 && !matchedCompany; const { data: health } = useQuery({ queryKey: queryKeys.health, queryFn: () => healthApi.get(), @@ -57,30 +71,30 @@ export function Layout() { useEffect(() => { if (!companyPrefix || companiesLoading || companies.length === 0) return; - const requestedPrefix = companyPrefix.toUpperCase(); - const matched = companies.find((company) => company.issuePrefix.toUpperCase() === requestedPrefix); - - if (!matched) { - const fallback = - (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null) - ?? companies[0]!; - navigate(`/${fallback.issuePrefix}/dashboard`, { replace: true }); + if (!matchedCompany) { + const fallback = (selectedCompanyId ? companies.find((company) => company.id === selectedCompanyId) : null) + ?? companies[0] + ?? null; + if (fallback && selectedCompanyId !== fallback.id) { + setSelectedCompanyId(fallback.id, { source: "route_sync" }); + } return; } - if (companyPrefix !== matched.issuePrefix) { + if (companyPrefix !== matchedCompany.issuePrefix) { const suffix = location.pathname.replace(/^\/[^/]+/, ""); - navigate(`/${matched.issuePrefix}${suffix}${location.search}`, { replace: true }); + navigate(`/${matchedCompany.issuePrefix}${suffix}${location.search}`, { replace: true }); return; } - if (selectedCompanyId !== matched.id) { - setSelectedCompanyId(matched.id, { source: "route_sync" }); + if (selectedCompanyId !== matchedCompany.id) { + setSelectedCompanyId(matchedCompany.id, { source: "route_sync" }); } }, [ companyPrefix, companies, companiesLoading, + matchedCompany, location.pathname, location.search, navigate, @@ -282,7 +296,14 @@ export function Layout() { className={cn("flex-1 overflow-auto p-4 md:p-6", isMobile && "pb-[calc(5rem+env(safe-area-inset-bottom))]")} onScroll={handleMainScroll} > - + {hasUnknownCompanyPrefix ? ( + + ) : ( + + )} diff --git a/ui/src/pages/NotFound.tsx b/ui/src/pages/NotFound.tsx new file mode 100644 index 00000000..bcaf3898 --- /dev/null +++ b/ui/src/pages/NotFound.tsx @@ -0,0 +1,66 @@ +import { useEffect } from "react"; +import { Link, useLocation } from "@/lib/router"; +import { AlertTriangle, Compass } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { useBreadcrumbs } from "../context/BreadcrumbContext"; +import { useCompany } from "../context/CompanyContext"; + +type NotFoundScope = "board" | "invalid_company_prefix" | "global"; + +interface NotFoundPageProps { + scope?: NotFoundScope; + requestedPrefix?: string; +} + +export function NotFoundPage({ scope = "global", requestedPrefix }: NotFoundPageProps) { + const location = useLocation(); + const { setBreadcrumbs } = useBreadcrumbs(); + const { companies, selectedCompany } = useCompany(); + + useEffect(() => { + setBreadcrumbs([{ label: "Not Found" }]); + }, [setBreadcrumbs]); + + const fallbackCompany = selectedCompany ?? companies[0] ?? null; + const dashboardHref = fallbackCompany ? `/${fallbackCompany.issuePrefix}/dashboard` : "/"; + const currentPath = `${location.pathname}${location.search}${location.hash}`; + const normalizedPrefix = requestedPrefix?.toUpperCase(); + + const title = scope === "invalid_company_prefix" ? "Company not found" : "Page not found"; + const description = + scope === "invalid_company_prefix" + ? `No company matches prefix "${normalizedPrefix ?? "unknown"}".` + : "This route does not exist."; + + return ( +
+
+
+
+ +
+
+

{title}

+

{description}

+
+
+ +
+ Requested path: {currentPath} +
+ +
+ + +
+
+
+ ); +}