Skip to content
Open
48 changes: 31 additions & 17 deletions components/analytics/runs-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -383,13 +383,16 @@ function StepLogRow({ step }: StepLogRowProps): ReactNode {
{step.error ? <StepErrorMessage message={step.error} /> : null}
</div>
</td>
<td className="whitespace-nowrap py-1.5 pr-3 text-xs text-muted-foreground">
{/* On a phone these three columns would push the row past the viewport.
The step's name/status/error (the monitoring essentials) stay; its
duration/network/gas return on desktop. */}
<td className="hidden py-1.5 pr-3 text-xs whitespace-nowrap text-muted-foreground md:table-cell">
{formatDuration(step.durationMs)}
</td>
<td className="whitespace-nowrap py-1.5 pr-3 text-xs text-muted-foreground">
<td className="hidden py-1.5 pr-3 text-xs whitespace-nowrap text-muted-foreground md:table-cell">
{step.network ? chains.name(step.network) : NO_VALUE}
</td>
<td className="whitespace-nowrap py-1.5 pr-3 text-xs text-muted-foreground">
<td className="hidden py-1.5 pr-3 text-xs whitespace-nowrap text-muted-foreground md:table-cell">
<span className="inline-flex items-center gap-1.5">
{formatGasNativeExact(step.gasCostWei, step.network, chains)}
{step.sponsored ? (
Expand All @@ -399,7 +402,7 @@ function StepLogRow({ step }: StepLogRowProps): ReactNode {
) : null}
</span>
</td>
<td />
<td className="hidden md:table-cell" />
</tr>
);
}
Expand All @@ -424,16 +427,16 @@ function ExpandedStepRows({
<div className="h-3 w-40 animate-pulse rounded bg-muted" />
</div>
</td>
<td className="py-2 pr-3">
<td className="hidden py-2 pr-3 md:table-cell">
<div className="h-3 w-12 animate-pulse rounded bg-muted" />
</td>
<td className="py-2 pr-3">
<td className="hidden py-2 pr-3 md:table-cell">
<div className="h-3 w-16 animate-pulse rounded bg-muted" />
</td>
<td className="py-2 pr-3">
<td className="hidden py-2 pr-3 md:table-cell">
<div className="h-3 w-14 animate-pulse rounded bg-muted" />
</td>
<td />
<td className="hidden md:table-cell" />
</tr>
)
)}
Expand Down Expand Up @@ -561,19 +564,19 @@ function ExpandableRunRow({ run }: ExpandableRunRowProps): ReactNode {
<td className="py-3 pr-3">
<StatusBadge status={run.status} />
</td>
<td className="py-3 pr-3">
<td className="hidden py-3 pr-3 md:table-cell">
<SourceBadge source={run.source} />
</td>
<td className="whitespace-nowrap py-3 pr-3 text-sm text-muted-foreground">
<td className="hidden py-3 pr-3 text-sm whitespace-nowrap text-muted-foreground md:table-cell">
{formatDuration(run.durationMs)}
</td>
<td
className="whitespace-nowrap py-3 pr-3 text-sm text-muted-foreground"
className="hidden py-3 pr-3 text-sm whitespace-nowrap text-muted-foreground md:table-cell"
title={run.networks.map(chains.name).join(", ")}
>
{formatNetworks(run.networks, chains)}
</td>
<td className="whitespace-nowrap py-3 pr-3 text-sm text-muted-foreground">
<td className="hidden py-3 pr-3 text-sm whitespace-nowrap text-muted-foreground md:table-cell">
{runGasDisplay(run, chains)}
</td>
<td className="whitespace-nowrap py-3 pr-3 text-right text-sm text-muted-foreground">
Expand Down Expand Up @@ -671,16 +674,27 @@ function RunsTableContent({

return (
<div className={cn("overflow-x-auto", pageLoading && "opacity-50")}>
<table className="min-w-[700px] w-full text-left">
<table className="w-full text-left md:min-w-[700px]">
<thead>
<tr className="border-b text-xs text-muted-foreground">
<th className="w-8 pb-2 pl-3" />
<th className="pb-2 pr-3 font-medium">Name</th>
<th className="pb-2 pr-3 font-medium">Status</th>
<th className="pb-2 pr-3 font-medium">Source</th>
<th className="pb-2 pr-3 font-medium">Duration</th>
<th className="pb-2 pr-3 font-medium">Network</th>
<th className="pb-2 pr-3 font-medium">Gas</th>
{/* Secondary columns stay on desktop; on a phone they are what
force the 700px pan, so they are hidden below md per the
mobile issue (2295). The expanded per-run rows repeat the
primary fields; the values hidden here are not re-exposed on a
phone. Desktop is unaffected. */}
<th className="hidden pb-2 pr-3 font-medium md:table-cell">
Source
</th>
<th className="hidden pb-2 pr-3 font-medium md:table-cell">
Duration
</th>
<th className="hidden pb-2 pr-3 font-medium md:table-cell">
Network
</th>
<th className="hidden pb-2 pr-3 font-medium md:table-cell">Gas</th>
<th className="pb-2 pr-3 text-right font-medium">Time</th>
</tr>
</thead>
Expand Down
104 changes: 28 additions & 76 deletions components/navigation-sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ import {
type WorkflowTriggerType,
} from "@/lib/workflow/store";
import { FLYOUT_WIDTH, FlyoutPanel, STRIP_WIDTH } from "./flyout-panel";
import {
ACTION_ITEM_IDS,
NAV_ITEMS_DATA,
type NavItemData,
type NavItemId,
SETTINGS_NAV_ITEM_DATA,
} from "./navigation/nav-items-data";

export const COLLAPSED_WIDTH = 60;
export const EXPANDED_WIDTH = 200;
Expand Down Expand Up @@ -455,24 +462,8 @@ function SidebarHeader({
);
}

const ACTION_ITEM_IDS: ReadonlySet<string> = new Set([
"workflows",
"address-book",
"activity",
]);

type NavItemDef = {
id: string;
type NavItemDef = NavItemData & {
icon: typeof Plus;
label: string;
href: string | null;
requireAuth: boolean;
// Visible only to organization owners/admins (the audit feed is gated the
// same way server-side).
adminOnly?: boolean;
// Visible only to organization owners (fund-moving surfaces like held
// payments; enforced server-side too).
ownerOnly?: boolean;
};

function NavItem({
Expand Down Expand Up @@ -542,71 +533,32 @@ function NavItem({
);
}

const NAV_ITEMS: NavItemDef[] = [
{
id: "hub",
icon: Globe,
label: "Hub",
href: "/hub",
requireAuth: false,
},
{
id: "workflows",
icon: WorkflowIcon,
label: "Workflows",
href: null,
requireAuth: false,
},
{
id: "analytics",
icon: BarChart3,
label: "Analytics",
href: "/analytics",
requireAuth: true,
},
{
id: "earnings",
icon: DollarSign,
label: "Earnings",
href: "/earnings",
requireAuth: true,
},
{
id: "held-payments",
icon: Clock,
label: "Held Payments",
href: "/held-payments",
requireAuth: true,
ownerOnly: true,
},
{
id: "address-book",
icon: Bookmark,
label: "Address Book",
href: null,
requireAuth: true,
},
{
// Visible to everyone and routable while signed-out: the page itself shows
// an in-page sign-in for guests, a labelled sample for members, and the
// real feed for owners/admins. So this is neither requireAuth nor adminOnly.
id: "activity",
icon: Activity,
label: "Activity",
href: "/activity",
requireAuth: false,
},
];
// Icons are resolved here, in the surface component, from the shared nav data
// (nav-items-data.ts holds no icons so tests can import it without the
// React/lucide runtime). Keyed by NavItemId so a destination added to
// NAV_ITEMS_DATA without an icon entry is a compile error, not a render crash.
const NAV_ICONS: Record<NavItemId, typeof Plus> = {
hub: Globe,
workflows: WorkflowIcon,
analytics: BarChart3,
earnings: DollarSign,
"held-payments": Clock,
"address-book": Bookmark,
activity: Activity,
settings: Settings,
};

const NAV_ITEMS: NavItemDef[] = NAV_ITEMS_DATA.map((item) => ({
...item,
icon: NAV_ICONS[item.id],
}));

// Settings is a destination, not a workspace view, so it sits at the foot of
// the nav column rather than among Hub / Workflows / Analytics -- above the
// divider that starts the external links, but pushed clear of Activity.
const SETTINGS_NAV_ITEM: NavItemDef = {
id: "settings",
...SETTINGS_NAV_ITEM_DATA,
icon: Settings,
label: "Settings",
href: "/settings",
requireAuth: true,
};

export function NavigationSidebar(): React.ReactNode {
Expand Down
100 changes: 100 additions & 0 deletions components/navigation/mobile-nav-items.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
import type { LucideIcon } from "lucide-react";
import { isAnonymousUser } from "@/lib/is-anonymous";
import {
NAV_ITEMS_DATA,
type NavItemId,
SETTINGS_NAV_ITEM_DATA,
} from "./nav-items-data";

export type MobileNavItem = {
id: NavItemId;
/** Presentation-only — resolved to a Lucide icon by the component. Kept off
* the data module so tests import zero React/lucide runtime. */
icon?: LucideIcon;
label: string;
href: string;
requireAuth: boolean;
ownerOnly?: boolean;
adminOnly?: boolean;
};

// The mobile nav derives from the same NAV_ITEMS_DATA the desktop sidebar
// renders, so a destination added to that one list either appears on both
// surfaces or fails the parity tests. Derivation rule: an item appears on
// mobile when it has a routable surface there - a desktop page (href) or a
// mobile route (mobileHref, used where desktop treats the item as a flyout
// with a null href). Items with neither (address-book) are desktop-only
// flyouts and stay off mobile. Settings is a destination on both surfaces and
// is appended from its own shared entry, matching its separate position at
// the foot of the desktop nav.
export const MOBILE_NAV_ITEMS: MobileNavItem[] = [
...NAV_ITEMS_DATA.flatMap((item) => {
const href = item.href ?? item.mobileHref;
if (!href) {
// Desktop-only flyout (address-book): no routable surface on mobile.
return [];
}
return [
{
id: item.id,
label: item.label,
href,
requireAuth: item.requireAuth,
ownerOnly: item.ownerOnly,
adminOnly: item.adminOnly,
},
];
}),
{
id: SETTINGS_NAV_ITEM_DATA.id,
label: SETTINGS_NAV_ITEM_DATA.label,
href: SETTINGS_NAV_ITEM_DATA.href,
requireAuth: SETTINGS_NAV_ITEM_DATA.requireAuth,
},
];

export type NavAccess = {
isAdmin: boolean;
isOwner: boolean;
};

/** The nav items a caller with this access level may see. */
export function visibleMobileNavItems(
access: NavAccess,
items: MobileNavItem[] = MOBILE_NAV_ITEMS
): MobileNavItem[] {
return items.filter(
(item) =>
(!item.adminOnly || access.isAdmin) && (!item.ownerOnly || access.isOwner)
);
}

/** Whether a route is the active one for a nav destination. */
export function isMobileNavActive(href: string, pathname: string): boolean {
if (href === "/") {
return pathname === "/";
}
return pathname === href || pathname.startsWith(`${href}/`);
}

export type NavDecision = { kind: "route" } | { kind: "auth-prompt" };

export type SessionUser = {
name?: string | null;
email?: string | null;
};

/**
* What tapping a nav item does: signed-out/anonymous users on a requireAuth
* destination are sent to the auth prompt; everyone else routes.
* `sessionUser` is the session's user object (may be null when signed out).
*/
export function decideMobileNavAction(
item: MobileNavItem,
sessionUser: SessionUser | null | undefined
): NavDecision {
if (item.requireAuth && isAnonymousUser(sessionUser)) {
return { kind: "auth-prompt" };
}
return { kind: "route" };
}
Loading
Loading