From fda0743eae734db88526f92fb511c98bb2b7e721 Mon Sep 17 00:00:00 2001 From: Matthew Meszaros Date: Wed, 7 Oct 2026 18:07:18 +0000 Subject: [PATCH] feat: clean application header with route-aware breadcrumbs and responsive workspace navigation Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- docs/content/docs/guides/unibox.mdx | 2 + web/src/components/layout/AppHeader.tsx | 128 ++++++-------------- web/src/hooks/useHeaderBreadcrumbs.test.tsx | 90 ++++++++++++++ web/src/hooks/useHeaderBreadcrumbs.ts | 64 ++++++++++ 4 files changed, 194 insertions(+), 90 deletions(-) create mode 100644 web/src/hooks/useHeaderBreadcrumbs.test.tsx create mode 100644 web/src/hooks/useHeaderBreadcrumbs.ts diff --git a/docs/content/docs/guides/unibox.mdx b/docs/content/docs/guides/unibox.mdx index 0193b89b7..6ffbe25d3 100644 --- a/docs/content/docs/guides/unibox.mdx +++ b/docs/content/docs/guides/unibox.mdx @@ -9,6 +9,8 @@ Verified [warmup mail](/guides/warmup/) stays out of Unibox, including your mail ## Layout +The top navigation shows your workspace and **Inbox**, without mailbox, folder or message identifiers. When a scope or conversation is open, click **Inbox** in the breadcrumb to return to the default inbox view. On phones, the header keeps the workspace picker and hides the section breadcrumbs. + The columns are yours to size. Every choice here is remembered in the browser you made it in, so the inbox opens the way you left it. | Control | What it does | diff --git a/web/src/components/layout/AppHeader.tsx b/web/src/components/layout/AppHeader.tsx index 329dfef4b..6df92f6bb 100644 --- a/web/src/components/layout/AppHeader.tsx +++ b/web/src/components/layout/AppHeader.tsx @@ -12,7 +12,7 @@ // AppShell, not here. import { useRef } from "react"; -import { Link, useLocation } from "@tanstack/react-router"; +import { Link } from "@tanstack/react-router"; import { ChevronRight, Menu, Search } from "lucide-react"; import { Logo } from "@/components/svg"; import AgentMark from "@/components/app/agent/AgentMark"; @@ -32,74 +32,16 @@ import { CreditsMeter } from "./CreditsMeter"; import { useIsMobile } from "@/hooks/use-mobile"; import { cn } from "@/lib/utils"; import { hrefTarget } from "@/lib/routerSearch"; - -// Pretty labels for path segments. Anything missing falls back to the -// raw segment with its first letter capitalised. -const labelMap: Record = { - app: "Home", - emails: "Accounts", - domains: "Sending domains", - unibox: "Inbox", - contacts: "Contacts", - segments: "Segments", - labels: "Labels", - campaigns: "Campaigns", - analytics: "Analytics", - crm: "CRM", - pipelines: "Pipelines", - deals: "Deals", - tasks: "Tasks", - templates: "Templates", - "api-keys": "API Keys", - settings: "Settings", - billing: "Billing", - team: "Team", - admin: "Admin", - workers: "Workers", - credentials: "Credentials", - audit: "Audit", - slack: "Slack", - link: "Link account", - leads: "Leads", - preferences: "Preferences", - schedule: "Schedule", - steps: "Steps", -}; - -// Crumb prefixes that are not pages of their own, sent to the page that lists them. -const crumbTargets: Record = { - "/app/placement/batches": "/app/placement?tab=batches", -}; - -function pretty(segment: string): string { - return labelMap[segment] ?? segment.charAt(0).toUpperCase() + segment.slice(1); -} +import { useHeaderBreadcrumbs } from "@/hooks/useHeaderBreadcrumbs"; export function AppHeader({ onMenu }: { onMenu?: () => void }) { - const pathname = useLocation({ select: (l) => l.pathname }); + const crumbs = useHeaderBreadcrumbs(); const setCommandPaletteOpen = useAppStore((s) => s.setCommandPaletteOpen); // The logo zone spans the sidebar column, so it has to collapse with it or // the breadcrumb stops lining up with the content panel below. const isMobile = useIsMobile(); const navCollapsed = useAppStore((s) => s.navCollapsed) && !isMobile; - // Path under /app — first segment is the section ("emails", "admin", ...), - // subsequent ones are subpages. Don't show UUID-looking segments verbatim - // because nobody wants "Campaigns > 47a3-..." in their chrome. - const segments = pathname - .split("/") - .filter(Boolean) - .filter((s) => s !== "app"); - // Each crumb links to its own path prefix so "Campaigns > Leads" gets you - // back to the list; hidden UUID segments still count toward the prefix. - const crumbs = segments - .map((seg, i) => ({ seg, to: `/app/${segments.slice(0, i + 1).join("/")}` })) - .filter(({ seg }) => !/^[0-9a-f]{8}-[0-9a-f]{4}/i.test(seg)); - // A crumb whose prefix is the page itself is a label; every other one is a - // link (so "Campaigns" stays clickable on /campaigns/, where the hidden - // id is the real last segment). - const currentPath = `/app/${segments.join("/")}`; - return (
{/* Logo zone — sidebar-width on >=md, compact with a menu button @@ -145,38 +87,46 @@ export function AppHeader({ onMenu }: { onMenu?: () => void }) { section crumbs are redundant with each page's own title on a phone, so they only show on >=md. */}
- +
- - {crumbs.map(({ seg, to }) => ( -
- - {to === currentPath ? ( - - {pretty(seg)} - - ) : ( - - {pretty(seg)} - - )} -
- ))} +
+
-
+
{/* Outside the sm-only group on purpose: once the dialog is dismissed this pill is the only way back to it, and a phone is exactly where someone dismisses it fastest. */}
- +
- -
+
+
@@ -184,12 +134,14 @@ export function AppHeader({ onMenu }: { onMenu?: () => void }) { @@ -198,10 +150,6 @@ export function AppHeader({ onMenu }: { onMenu?: () => void }) { ); } -function Crumb({ children }: { children: React.ReactNode }) { - return
{children}
; -} - // Remie's toggle. Working, waiting on an approval and finishing all show on the // blob itself, the dot marks an unread reply, and tips hang off it (RemieTip). function AssistantButton() { diff --git a/web/src/hooks/useHeaderBreadcrumbs.test.tsx b/web/src/hooks/useHeaderBreadcrumbs.test.tsx new file mode 100644 index 000000000..aad514d5b --- /dev/null +++ b/web/src/hooks/useHeaderBreadcrumbs.test.tsx @@ -0,0 +1,90 @@ +import { act, cleanup, render, screen, waitFor } from "@testing-library/react"; +import { createMemoryHistory, createRootRoute, createRoute, createRouter, Link, Outlet, RouterProvider } from "@tanstack/react-router"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { hrefTarget } from "@/lib/routerSearch"; +import { useHeaderBreadcrumbs } from "./useHeaderBreadcrumbs"; + +function Breadcrumbs() { + const crumbs = useHeaderBreadcrumbs(); + return ; +} + +async function open(path: string, href: string) { + const root = createRootRoute({ component: Outlet }); + const route = createRoute({ getParentRoute: () => root, path, component: Breadcrumbs }); + const router = createRouter({ + routeTree: root.addChildren([route]), + history: createMemoryHistory({ initialEntries: [href] }), + scrollRestoration: false, + }); + await act(() => router.load()); + render(); + return router; +} + +beforeEach(() => vi.spyOn(window, "scrollTo").mockImplementation(() => {})); +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); +}); + +describe("header breadcrumbs", () => { + it.each([ + "/app/unibox", + "/app/unibox/all", + "/app/unibox/account-123/AAQkAGFiNzBmMDQ2LWM1ZmUtNGYyNC05NTk5LTg0NjFmZGFhMTc5ZgAQAA_m_very_long_provider_message_id", + "/app/unibox/label%3Aimportant/thread-123", + "/app/unibox/app/ordinary-id", + ])("shows only Inbox for %s, never scope or thread parameters", async (href) => { + await open("/app/unibox/{-$scope}/{-$threadId}", href); + expect(screen.getByRole("navigation")).toHaveTextContent(/^Inbox$/); + if (href === "/app/unibox") { + expect(screen.getByText("Inbox")).toHaveAttribute("aria-current", "page"); + } else { + expect(screen.getByRole("link", { name: "Inbox" })).toHaveAttribute("href", "/app/unibox"); + } + }); + + it.each(["campaign-slug", "00000000-0000-0000-0000-000000000001", "app", "a%2Fb"])("preserves %s in nested link targets without displaying it", async (id) => { + await open("/app/campaigns/$id/leads/details", `/app/campaigns/${id}/leads/details`); + expect(screen.getByRole("link", { name: "Campaigns" })).toHaveAttribute("href", "/app/campaigns"); + expect(screen.getByRole("link", { name: "Leads" })).toHaveAttribute("href", `/app/campaigns/${id}/leads`); + expect(screen.getByText("Details")).toHaveAttribute("aria-current", "page"); + expect(screen.getByRole("navigation")).toHaveTextContent(/^CampaignsLeadsDetails$/); + }); + + it("updates navigation when the matched route parameters change", async () => { + const router = await open("/app/campaigns/$id/leads/details", "/app/campaigns/first/leads/details"); + const to: string = "/app/campaigns/second/leads/details"; + await act(() => router.navigate({ to })); + await waitFor(() => expect(screen.getByRole("link", { name: "Leads" })).toHaveAttribute("href", "/app/campaigns/second/leads")); + }); + + it("routes the batches ancestor to the existing tab", async () => { + await open("/app/placement/batches/$id", "/app/placement/batches/batch-id"); + expect(screen.getByRole("link", { name: "Placement tests" })).toHaveAttribute("href", "/app/placement"); + expect(screen.getByRole("link", { name: "Batches" })).toHaveAttribute("href", "/app/placement?tab=batches"); + expect(screen.getByRole("navigation")).toHaveTextContent(/^Placement testsBatches$/); + }); + + it("hides splat contents even when they span several segments", async () => { + await open("/app/integrations/$", "/app/integrations/store/category/item-id"); + expect(screen.getByRole("navigation")).toHaveTextContent(/^Integrations$/); + expect(screen.getByRole("link", { name: "Integrations" })).toHaveAttribute("href", "/app/integrations"); + }); + + it("uses readable static labels and marks trailing-slash pages current", async () => { + await open("/app/settings/ai-skills", "/app/settings/ai-skills/"); + expect(screen.getByRole("link", { name: "Settings" })).toHaveAttribute("href", "/app/settings"); + expect(screen.getByText("AI skills")).toHaveAttribute("aria-current", "page"); + }); + + it("makes unmapped hyphenated page names readable", async () => { + await open("/app/settings/custom-page", "/app/settings/custom-page"); + expect(screen.getByText("Custom page")).toHaveAttribute("aria-current", "page"); + }); +}); diff --git a/web/src/hooks/useHeaderBreadcrumbs.ts b/web/src/hooks/useHeaderBreadcrumbs.ts new file mode 100644 index 000000000..548d6eef2 --- /dev/null +++ b/web/src/hooks/useHeaderBreadcrumbs.ts @@ -0,0 +1,64 @@ +import { useMatches, useRouter } from "@tanstack/react-router"; + +const labelMap: Record = { + emails: "Accounts", + domains: "Sending domains", + unibox: "Inbox", + contacts: "Contacts", + segments: "Segments", + labels: "Labels", + campaigns: "Campaigns", + analytics: "Analytics", + crm: "CRM", + pipelines: "Pipelines", + deals: "Deals", + tasks: "Tasks", + templates: "Templates", + "api-keys": "API Keys", + settings: "Settings", + billing: "Billing", + team: "Team", + admin: "Admin", + workers: "Workers", + credentials: "Credentials", + audit: "Audit", + slack: "Slack", + link: "Link account", + leads: "Leads", + preferences: "Preferences", + schedule: "Schedule", + steps: "Steps", + placement: "Placement tests", + "ai-skills": "AI skills", + "warmbly-cloud": "Warmbly Cloud", + "oauth-apps": "OAuth apps", + "inbox-tagging": "Inbox tagging", + hubspot: "HubSpot", + pipedrive: "Pipedrive", + salesforce: "Salesforce", +}; + +const crumbTargets: Record = { + "/app/placement/batches": "/app/placement?tab=batches", +}; + +export function useHeaderBreadcrumbs() { + const router = useRouter(); + const matches = useMatches(); + const match = matches[matches.length - 1]; + if (!match) return []; + + // Only static route segments are navigation; params describe the selected record or scope. + const segments = match.fullPath.split("/").filter(Boolean); + return segments.flatMap((segment, index) => { + if (index === 0 || segment.includes("$")) return []; + const template = `/${segments.slice(0, index + 1).join("/")}`; + const to = router.buildLocation({ to: template, params: match.params }).pathname; + const words = segment.replaceAll("-", " "); + return [{ + label: labelMap[segment] ?? words.charAt(0).toUpperCase() + words.slice(1), + to: crumbTargets[to] ?? to, + current: to.replace(/\/$/, "") === match.pathname.replace(/\/$/, ""), + }]; + }); +}