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>
This commit is contained in:
Matthew Meszaros
2026-10-07 18:07:18 +00:00
co-authored by Devin AI
parent ef7892f769
commit fda0743eae
4 changed files with 194 additions and 90 deletions
+2
View File
@@ -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 |
+38 -90
View File
@@ -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<string, string> = {
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<string, string> = {
"/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/<id>, where the hidden
// id is the real last segment).
const currentPath = `/app/${segments.join("/")}`;
return (
<div className="h-14 flex items-center shrink-0">
{/* 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. */}
<div className="flex items-center gap-1.5 min-w-0 flex-1 pr-2 md:pr-4">
<Crumb>
<div className="min-w-0 md:shrink-0 max-w-32 lg:max-w-48">
<OrgSwitcher />
</Crumb>
{crumbs.map(({ seg, to }) => (
<div key={to} className="hidden md:flex items-center gap-2 min-w-0">
<ChevronRight className="w-3.5 h-3.5 text-slate-300 shrink-0" />
{to === currentPath ? (
<span className="text-[13px] font-medium text-slate-900 truncate">
{pretty(seg)}
</span>
) : (
<Link
{...hrefTarget(crumbTargets[to] ?? to)}
className="text-[13px] text-slate-500 hover:text-slate-900 truncate transition-colors"
>
{pretty(seg)}
</Link>
)}
</div>
))}
</div>
<nav aria-label="Breadcrumb" className="hidden md:block min-w-0">
<ol className="flex items-center gap-2 min-w-0">
{crumbs.map(({ label, to, current }, index) => (
<li key={to} className="flex items-center gap-2 min-w-0">
<ChevronRight aria-hidden="true" className="w-3.5 h-3.5 text-slate-300 shrink-0" />
{current ? (
<span aria-current="page" title={label} className="text-[13px] font-medium text-slate-900 truncate">
{label}
</span>
) : (
<Link
{...hrefTarget(to)}
title={label}
className={cn(
"text-[13px] hover:text-slate-900 truncate transition-colors",
index === crumbs.length - 1 ? "font-medium text-slate-900" : "text-slate-500",
)}
>
{label}
</Link>
)}
</li>
))}
</ol>
</nav>
</div>
<div className="flex items-center gap-2 px-2 sm:px-4 shrink-0">
<div className="flex items-center gap-1 sm:gap-2 px-2 sm:px-4 shrink-0">
{/* 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. */}
<BetaPill />
<div className="hidden sm:flex items-center gap-2">
<PlanPill />
<div className="hidden lg:contents"><PlanPill /></div>
<VersionPill />
<CreditsMeter />
<div className="h-4 w-px bg-slate-200/80" />
<div className="hidden lg:contents"><CreditsMeter /></div>
<div className="hidden lg:block h-4 w-px bg-slate-200/80" />
</div>
<OutboxIndicator />
<PresenceAvatars />
@@ -184,12 +134,14 @@ export function AppHeader({ onMenu }: { onMenu?: () => void }) {
<NotificationBell />
<AssistantButton />
<button
type="button"
aria-label="Search"
onClick={() => setCommandPaletteOpen(true)}
className="flex items-center gap-2 px-2 h-7 rounded-md text-slate-500 hover:text-slate-900 hover:bg-slate-200/60 transition-colors text-[12.5px]"
>
<Search className="w-3.5 h-3.5" />
<span className="hidden sm:inline">Search</span>
<kbd className="hidden md:inline-flex h-4 items-center px-1 rounded border border-slate-300/70 bg-white/60 font-mono text-[10px] text-slate-500 ml-0.5">
<span className="hidden xl:inline">Search</span>
<kbd className="hidden xl:inline-flex h-4 items-center px-1 rounded border border-slate-300/70 bg-white/60 font-mono text-[10px] text-slate-500 ml-0.5">
⌘K
</kbd>
</button>
@@ -198,10 +150,6 @@ export function AppHeader({ onMenu }: { onMenu?: () => void }) {
);
}
function Crumb({ children }: { children: React.ReactNode }) {
return <div className="flex items-center gap-2 min-w-0">{children}</div>;
}
// 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() {
@@ -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 <nav aria-label="Breadcrumb">
{crumbs.map(({ label, to, current }) => current
? <span key={to} aria-current="page">{label}</span>
: <Link key={to} {...hrefTarget(to)}>{label}</Link>)}
</nav>;
}
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(<RouterProvider router={router} />);
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");
});
});
+64
View File
@@ -0,0 +1,64 @@
import { useMatches, useRouter } from "@tanstack/react-router";
const labelMap: Record<string, string> = {
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<string, string> = {
"/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(/\/$/, ""),
}];
});
}