mirror of
https://github.com/warmbly/warmbly.git
synced 2026-10-06 08:02:06 +00:00
251 lines
11 KiB
TypeScript
251 lines
11 KiB
TypeScript
// Top breadcrumb bar.
|
|
//
|
|
// Reads as one continuous line across the entire top of the shell:
|
|
//
|
|
// [Warmbly logo] > [Org picker] > [Current section] [⌘K ⚡]
|
|
//
|
|
// The logo sits over the sidebar column, the org picker + section live
|
|
// in the open area, the right side has connection indicator + search.
|
|
// All on the sky-colored chrome — text is white-ish, dividers are faint.
|
|
//
|
|
// This component is purely the row. Layout (where it sits) is decided by
|
|
// AppShell, not here.
|
|
|
|
import { useRef } from "react";
|
|
import { Link, useLocation } from "react-router-dom";
|
|
import { ChevronRight, Menu, Search } from "lucide-react";
|
|
import { Logo } from "@/components/svg";
|
|
import AgentMark from "@/components/app/agent/AgentMark";
|
|
import RemieTip from "@/components/app/agent/RemieTip";
|
|
import { useAppStore } from "@/stores";
|
|
import { ConnectionIndicator } from "@/components/shared/ConnectionIndicator";
|
|
import { usePermission } from "@/hooks/usePermission";
|
|
import ShortcutTooltip from "@/components/ui/shortcut-tooltip";
|
|
import PresenceAvatars from "@/components/app/presence/PresenceAvatars";
|
|
import OutboxIndicator from "@/components/app/unibox/compose/OutboxIndicator";
|
|
import { NotificationBell } from "./NotificationBell";
|
|
import { OrgSwitcher } from "./OrgSwitcher";
|
|
import { BetaPill } from "./BetaPill";
|
|
import { PlanPill } from "./PlanPill";
|
|
import { VersionPill } from "./VersionPill";
|
|
import { CreditsMeter } from "./CreditsMeter";
|
|
import { useIsMobile } from "@/hooks/use-mobile";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// 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",
|
|
};
|
|
|
|
function pretty(segment: string): string {
|
|
return labelMap[segment] ?? segment.charAt(0).toUpperCase() + segment.slice(1);
|
|
}
|
|
|
|
export function AppHeader({ onMenu }: { onMenu?: () => void }) {
|
|
const { pathname } = useLocation();
|
|
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
|
|
on mobile (the sidebar collapses into a drawer below md). */}
|
|
<button
|
|
type="button"
|
|
onClick={onMenu}
|
|
aria-label="Open menu"
|
|
className="md:hidden ml-1.5 w-9 h-9 rounded-md flex items-center justify-center text-slate-600 hover:text-slate-900 hover:bg-slate-200/60 transition-colors shrink-0"
|
|
>
|
|
<Menu className="w-5 h-5" />
|
|
</button>
|
|
<Link
|
|
to="/app/emails"
|
|
className={cn(
|
|
"h-full flex items-center gap-2.5 shrink-0 group pl-2 pr-3 md:transition-[width,padding] md:duration-200 md:ease-out",
|
|
navCollapsed ? "md:w-14 md:px-0 md:justify-center" : "md:w-64 md:px-5",
|
|
)}
|
|
>
|
|
{/* Cool blue-leaning gray at rest; deeper blue-gray on hover.
|
|
Light enough to read as neutral chrome, but with a clear
|
|
blue lean so the brand sneaks in. */}
|
|
{/* Logo color tuned to read as a real brand mark, not
|
|
a washed-out accent. Deep slate (#0f172a) at rest +
|
|
slight warm shift on hover. The earlier blue-gray
|
|
was too pale and competed with the chrome rather
|
|
than anchoring it. */}
|
|
<Logo className="w-7 text-slate-900 group-hover:text-slate-700 transition-colors duration-150" />
|
|
{/* Wordmark hides on mobile — the mark + the drawer's own brand
|
|
header carry it there, leaving room for the workspace pill. */}
|
|
<span
|
|
style={{ fontFamily: "var(--font-display)" }}
|
|
className={cn(
|
|
"wb-title font-extrabold text-[15.5px] tracking-tight text-slate-900",
|
|
navCollapsed ? "hidden" : "hidden md:inline",
|
|
)}
|
|
>
|
|
Warmbly
|
|
</span>
|
|
</Link>
|
|
|
|
{/* Breadcrumb: org switcher (always) > section > subpages. The
|
|
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>
|
|
<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
|
|
to={to}
|
|
className="text-[13px] text-slate-500 hover:text-slate-900 truncate transition-colors"
|
|
>
|
|
{pretty(seg)}
|
|
</Link>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex items-center 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 />
|
|
<VersionPill />
|
|
<CreditsMeter />
|
|
<div className="h-4 w-px bg-slate-200/80" />
|
|
</div>
|
|
<OutboxIndicator />
|
|
<PresenceAvatars />
|
|
<ConnectionIndicator />
|
|
<NotificationBell />
|
|
<AssistantButton />
|
|
<button
|
|
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">
|
|
⌘K
|
|
</kbd>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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() {
|
|
const open = useAppStore((s) => s.aiAssistantOpen);
|
|
const minimized = useAppStore((s) => s.agentMinimized);
|
|
const setOpen = useAppStore((s) => s.setAIAssistantOpen);
|
|
const setMinimized = useAppStore((s) => s.setAgentMinimized);
|
|
const tabs = useAppStore((s) => s.agentTabs);
|
|
const canAI = usePermission("USE_AI");
|
|
const lastRunOk = useAppStore((s) => s.agentLastRunOk);
|
|
const anchor = useRef<HTMLDivElement>(null);
|
|
|
|
if (!canAI) return null;
|
|
|
|
const running = tabs.some((t) => t.running);
|
|
const pending = tabs.some((t) => t.pending);
|
|
const unseen = tabs.some((t) => t.unseen);
|
|
|
|
return (
|
|
// Relative so Remie's tip bubble can hang off the blob.
|
|
<div ref={anchor} className="relative">
|
|
<ShortcutTooltip label="Ask Remie" combo="mod+I" side="bottom">
|
|
<button
|
|
onClick={() => {
|
|
if (open && minimized) {
|
|
// Docked: bring the panel back instead of closing it.
|
|
setMinimized(false);
|
|
} else if (open) {
|
|
setOpen(false);
|
|
} else {
|
|
setMinimized(false);
|
|
setOpen(true);
|
|
}
|
|
}}
|
|
aria-label="Ask Remie"
|
|
className="group relative flex items-center justify-center size-7 rounded-md hover:bg-sky-50 transition-colors"
|
|
>
|
|
<AgentMark
|
|
size={20}
|
|
celebrate={lastRunOk}
|
|
state={pending ? "attention" : running ? "thinking" : "idle"}
|
|
/>
|
|
{/* Working and waiting show on the mark itself; the dot is an unread reply. */}
|
|
{unseen && !running && !pending && (
|
|
<span className="absolute top-0.5 right-0.5 size-1.5 rounded-full bg-sky-500 ring-2 ring-white" />
|
|
)}
|
|
</button>
|
|
</ShortcutTooltip>
|
|
<RemieTip anchor={anchor} />
|
|
</div>
|
|
);
|
|
}
|