mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-12 16:02:44 +00:00
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:
co-authored by
Devin AI
parent
ef7892f769
commit
fda0743eae
@@ -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 |
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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(/\/$/, ""),
|
||||
}];
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user