diff --git a/app/(app)/apps/[id]/page.tsx b/app/(app)/apps/[id]/page.tsx
index 808d5fc..a6f0310 100644
--- a/app/(app)/apps/[id]/page.tsx
+++ b/app/(app)/apps/[id]/page.tsx
@@ -331,14 +331,7 @@ function ApiTab({ model }: { model: App }) {
Drop this into the{" "}
- Authorization header below, or{" "}
-
- manage your keys
-
- .
+ Authorization header below.
@@ -371,10 +364,10 @@ function ApiTab({ model }: { model: App }) {
Billed per request. Free tier covers your first 10,000 each month.
- Add a payment provider →
+ View usage →
diff --git a/app/(app)/calls/page.tsx b/app/(app)/calls/page.tsx
index 59c6079..de668fb 100644
--- a/app/(app)/calls/page.tsx
+++ b/app/(app)/calls/page.tsx
@@ -1,13 +1,13 @@
import { redirect } from "next/navigation";
/**
- * `/calls` folded into `/usage` for the creator pilot — the call log now
- * renders underneath Spend by capability rather than as its own destination.
+ * `/calls` folded into `/home` for the creator pilot — the call log now
+ * renders on Home rather than as its own destination.
*
* The route stays as a redirect because `?request=` links to a single call
* are already in the wild (the app-detail log table, the Home activity panel
* before it was removed, anything anyone bookmarked). The param carries over
- * so those still open the call drawer, just on Usage.
+ * so those still open the call drawer, just on Home.
*/
export default async function CallsPage({
searchParams,
@@ -17,5 +17,5 @@ export default async function CallsPage({
const params = await searchParams;
const request = params.request;
const id = Array.isArray(request) ? request[0] : request;
- redirect(id ? `/usage?request=${encodeURIComponent(id)}` : "/usage");
+ redirect(id ? `/home?request=${encodeURIComponent(id)}` : "/home");
}
diff --git a/app/(app)/device/DeviceApproveForm.tsx b/app/(app)/device/DeviceApproveForm.tsx
index d06f257..1249ef3 100644
--- a/app/(app)/device/DeviceApproveForm.tsx
+++ b/app/(app)/device/DeviceApproveForm.tsx
@@ -1,14 +1,13 @@
"use client";
import { useState, type ReactNode } from "react";
-import { Smartphone } from "lucide-react";
import Button from "@/components/design-system/Button";
import ConsolePageHeader from "@/components/console/ConsolePageHeader";
export function DevicePageChrome({ children }: { children: ReactNode }) {
return (
<>
-
+
{children}
>
);
diff --git a/app/(app)/home/page.tsx b/app/(app)/home/page.tsx
index f82cb05..7e4f899 100644
--- a/app/(app)/home/page.tsx
+++ b/app/(app)/home/page.tsx
@@ -1,86 +1,33 @@
"use client";
-import { useEffect } from "react";
-import { useRouter } from "next/navigation";
-import { House } from "lucide-react";
+import { Suspense } from "react";
import { useAuth } from "@/components/console/AuthContext";
-import ConsolePageHeader from "@/components/console/ConsolePageHeader";
-import HomeCommandBar from "@/components/console/HomeCommandBar";
-import McpConnectPanel from "@/components/console/McpConnectPanel";
-
-function HomePageHeader() {
- return ;
-}
-
-// ─── Home Page ───
-//
-// One job: get the MCP endpoint into the user's agent.
-//
-// Home used to be an operations dashboard — a first-run checklist, a spend
-// panel and a recent-activity preview. All three came out for the creator
-// pilot:
-//
-// • the checklist walked through provisioning an API key, and the pilot
-// provisions none — connecting a harness is the whole of onboarding now
-// • the spend panel and activity preview restated /usage, which is one
-// click away in the rail and carries the real versions
-//
-// What's left is deliberately sparse. A creator arriving here should be a
-// couple of copies away from having Livepeer inside their agent, and the page
-// should read as early — because it is.
-//
-// The running balance is not repeated here: it sits in the sidebar card, which
-// is on screen for every route rather than only this one.
+import ConsolePageSkeleton from "@/components/console/ConsolePageSkeleton";
+import SignInWall from "@/components/console/SignInWall";
+import UsageView from "@/components/console/UsageView";
export default function HomePage() {
- const { isConnected, isLoading, user } = useAuth();
- const router = useRouter();
+ return (
+ }>
+
+
+ );
+}
- // Middleware already sends signed-out requests to /login before this page
- // is served (see middleware.ts). This client-side fallback only fires if
- // the session lapses while the console is open.
- useEffect(() => {
- if (!isLoading && (!isConnected || !user)) {
- router.replace("/login");
- }
- }, [isLoading, isConnected, user, router]);
+function HomeContent() {
+ const { isConnected, isLoading } = useAuth();
+ // Avoid flashing the wall while auth hydrates.
if (isLoading) return null;
- // Redirect is in flight; render nothing while it takes effect.
- if (!isConnected || !user) return null;
-
- const firstName = user.name.split(" ")[0] || "there";
+ // Organization-only route — logged-out users see the in-shell sign-in wall
+ // instead of a hard redirect.
+ if (!isConnected) return ;
return (
-
- {/* Atmosphere — a faint brand-green aura bleeding from the top edge, so
- the console reads as a lit panel rather than a flat page. */}
-
-
-
-
-
-
-
-
- Livepeer Agent runs as an MCP server your agent runtime calls
- directly. Connect one below and every capability on the network —
- video, image, audio, 3D — becomes something you can just ask for.
-
+ );
+}
+
+export default function InstallPage() {
+ const { isConnected, isLoading } = useAuth();
+ const router = useRouter();
+
+ // Middleware already sends signed-out requests to /login before this page
+ // is served (see middleware.ts). This client-side fallback only fires if
+ // the session lapses while the console is open.
+ useEffect(() => {
+ if (!isLoading && !isConnected) {
+ router.replace("/login");
+ }
+ }, [isLoading, isConnected, router]);
+
+ if (isLoading) return null;
+
+ // Redirect is in flight; render nothing while it takes effect.
+ if (!isConnected) return null;
+
+ return (
+
+
+
+
+ Turn your agent into a full suite production studio.
+
+
+ Bring image, video, audio, 3D, editing, rendering, and production
+ tools into your agent’s workflows with Livepeer.
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(app)/layout.tsx b/app/(app)/layout.tsx
index caa6d88..db42801 100644
--- a/app/(app)/layout.tsx
+++ b/app/(app)/layout.tsx
@@ -1,7 +1,5 @@
import type { Metadata } from "next";
import type { CSSProperties } from "react";
-import { GeistSans } from "geist/font/sans";
-import { GeistMono } from "geist/font/mono";
import { AuthProvider } from "@/components/console/AuthContext";
import { EnvironmentProvider } from "@/components/console/EnvironmentContext";
import { ThemeProvider } from "@/components/console/ThemeContext";
@@ -9,40 +7,24 @@ import ConsoleSidebar from "@/components/console/ConsoleSidebar";
import KeyboardShortcuts from "@/components/console/KeyboardShortcuts";
// FOUT prevention — runs synchronously in the document, before the console
-// subtree paints. Reads the stored theme preference from localStorage and
-// applies `` so dual-source CSS variables resolve to
-// the right theme on first paint. The ThemeProvider takes over after
-// hydration; this is just the bootstrap.
-//
-// Reads the same `localStorage["theme"]` key the marketing site uses (see
-// `app/layout.tsx`'s inline script and `components/layout/ThemeToggle.tsx`),
-// so flipping the theme on either surface propagates to the other. Default
-// is "system" for first-time visitors — we follow the OS `prefers-color-scheme`
-// until the user pins light or dark via Settings → Appearance.
-const THEME_INIT_SCRIPT = `(function(){try{var s=localStorage.getItem('theme')||'system';var d=s==='dark'||(s!=='light'&&matchMedia('(prefers-color-scheme: dark)').matches);document.documentElement.dataset.theme=d?'dark':'light';}catch(e){document.documentElement.dataset.theme='dark';}})();`;
+// subtree paints. Theme is system-only, so first paint follows the OS
+// `prefers-color-scheme` result directly; ThemeProvider keeps it in sync after
+// hydration.
+const THEME_INIT_SCRIPT = `(function(){try{document.documentElement.dataset.theme=matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light';}catch(e){document.documentElement.dataset.theme='dark';}})();`;
export const metadata: Metadata = {
- title: "Console — Livepeer",
+ title: "Livepeer Early Access",
description:
- "Browse AI apps, manage API keys, and monitor usage on the Livepeer network.",
+ "Explore Livepeer AI apps, manage API access, and track usage during early access.",
};
-// The console runs on Geist (Vercel's open-source font) instead of Favorit Pro —
-// the console is a *tool*, the marketing site is the brand. We attach the Geist
-// CSS variables to this subtree and override `--font-sans` / `--font-mono` so
-// every Tailwind `font-sans` / `font-mono` consumer below this point picks Geist.
-//
-// Density: per the Livepeer Console design (Claude Design handoff, Apr 2026),
-// the console subtree uses a 13.5px body with a slightly tighter letter-spacing
-// to land in the same density bracket as Linear. Sidebar width and chrome head
-// height are exposed as custom properties so components can reference them.
+// Product surfaces use the registry theme's Inter-backed `font-sans`.
+// Sidebar width and chrome head height are exposed as custom properties so
+// components can reference them.
const consoleOverrides = {
- "--font-sans": "var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif",
- "--font-mono": "var(--font-geist-mono), ui-monospace, monospace",
- "--side-w": "232px",
+ "--side-w": "256px",
"--head-h": "44px",
fontSize: "13.5px",
- letterSpacing: "-0.005em",
} as CSSProperties;
export default function ConsoleLayout({
@@ -62,11 +44,11 @@ export default function ConsoleLayout({
-
+
{children}
diff --git a/app/(app)/network/page.tsx b/app/(app)/network/page.tsx
index ba6c7d8..e768439 100644
--- a/app/(app)/network/page.tsx
+++ b/app/(app)/network/page.tsx
@@ -5,7 +5,6 @@ import { useSearchParams, useRouter, usePathname } from "next/navigation";
import {
BarChart3,
Activity,
- Globe,
Wallet,
Cpu,
ArrowUpRight,
@@ -106,8 +105,7 @@ function NetworkContent() {
return (
diff --git a/app/(app)/settings/page.tsx b/app/(app)/settings/page.tsx
index f98551f..7fb4b3a 100644
--- a/app/(app)/settings/page.tsx
+++ b/app/(app)/settings/page.tsx
@@ -1,150 +1,5 @@
-"use client";
+import { redirect } from "next/navigation";
-import { Suspense, useEffect } from "react";
-import Link from "next/link";
-import { useSearchParams, useRouter, usePathname } from "next/navigation";
-import { Settings as SettingsIcon } from "lucide-react";
-import { useAuth } from "@/components/console/AuthContext";
-import ConsolePageSkeleton from "@/components/console/ConsolePageSkeleton";
-import SignInWall from "@/components/console/SignInWall";
-import AccountSection from "@/components/console/settings/AccountSection";
-import AppearanceSection from "@/components/console/settings/AppearanceSection";
-
-// Settings is two tabs for the creator pilot:
-// - `account` — the merged former General + Profile (see AccountSection)
-// - `appearance` — the local-only theme picker (light/dark/system)
-//
-// Four tabs are hidden rather than deleted; their sections still exist under
-// components/console/settings/ and come back by re-adding them here and to
-// SETTINGS_RAIL_ITEMS in ConsoleSidebar:
-// - `members` — team workspaces are backlogged; nothing behind the UI.
-// - `billing` — blocked on an unresolved question about which entity
-// bills (PymtHouse vs. Foundation vs. Inc). Shipping a
-// billing page before that is settled reads as "billing
-// is done" when the dependency is still open.
-// - `notifications` — no notification delivery exists.
-// - `security` — session/2FA controls are owned by Auth0, not by us.
-//
-// No "usage-limits" tab: concurrent streams, per-key rate limits and allowed
-// regions were dropped (Aug 2026), and the one limit worth keeping — the hard
-// spend cap — belongs on the Usage meter, not in a settings form. See the
-// Spend cap note in CLAUDE.md.
-type SettingsTab = "account" | "appearance";
-
-const VALID_TABS: SettingsTab[] = ["account", "appearance"];
-
-const TAB_LABELS: Record = {
- account: "Account",
- appearance: "Appearance",
-};
-
-/**
- * Old tab ids that still resolve. `organization` and `profile` are the two
- * pages that merged into `account`; the rest are hidden sections whose links
- * are still in the wild (bookmarks, the org menu's old Billing entry). All of
- * them land on Account rather than 404-ing or rendering a hidden section.
- */
-const RETIRED_TABS = new Set([
- "organization",
- "profile",
- "members",
- "billing",
- "notifications",
- "security",
-]);
-
-export default function SettingsPage() {
- return (
-
- }
- >
-
-
- );
-}
-
-function SettingsContent() {
- const { isConnected, isLoading } = useAuth();
- const searchParams = useSearchParams();
- const router = useRouter();
- const pathname = usePathname();
-
- const rawTab = searchParams.get("tab");
-
- // Back-compat redirects for old tab ids.
- // - `tab=tokens` → /keys (kept so the URL still resolves even though API
- // keys is out of the nav for the pilot)
- // - `tab=usage` → /usage (top-level route)
- // - retired/merged tabs → drop the param entirely, landing on Account
- useEffect(() => {
- if (rawTab === "tokens") {
- router.replace("/keys");
- } else if (rawTab === "usage") {
- router.replace("/usage");
- } else if (rawTab === "account" || rawTab === null) {
- // Already canonical — nothing to do.
- } else if (
- RETIRED_TABS.has(rawTab) ||
- !VALID_TABS.includes(rawTab as SettingsTab)
- ) {
- const params = new URLSearchParams(searchParams.toString());
- params.delete("tab");
- const qs = params.toString();
- router.replace(`${pathname}${qs ? `?${qs}` : ""}`, { scroll: false });
- }
- }, [rawTab, router, pathname, searchParams]);
-
- // Default to "account" when no tab param is set.
- const tab: SettingsTab = VALID_TABS.includes(rawTab as SettingsTab)
- ? (rawTab as SettingsTab)
- : "account";
-
- // Wait for auth to hydrate so we don't flash the wrong state.
- if (isLoading) return null;
-
- // Organization-only — logged-out users see the sign-in wall.
- if (!isConnected) return ;
-
- return (
-
- {/* Breadcrumb chrome bar — Settings / {sub-tab label}. Mirrors the v7
- prototype's `` which uses the cog icon on
- the first crumb and the active tab label on the second. */}
-
-
- );
+export default function LegacySettingsPage() {
+ redirect("/home");
}
diff --git a/app/(app)/usage/page.tsx b/app/(app)/usage/page.tsx
index 7a25299..49a2852 100644
--- a/app/(app)/usage/page.tsx
+++ b/app/(app)/usage/page.tsx
@@ -1,47 +1,5 @@
-"use client";
+import { redirect } from "next/navigation";
-import { Suspense } from "react";
-import { BarChart3 } from "lucide-react";
-import { useAuth } from "@/components/console/AuthContext";
-import ConsolePageHeader from "@/components/console/ConsolePageHeader";
-import ConsolePageSkeleton from "@/components/console/ConsolePageSkeleton";
-import SignInWall from "@/components/console/SignInWall";
-import UsageView from "@/components/console/UsageView";
-
-export default function UsagePage() {
- return (
- }>
-
-
- );
-}
-
-function UsageContent() {
- const { isConnected, isLoading } = useAuth();
-
- // Avoid flashing the wall while auth hydrates.
- if (isLoading) return null;
-
- // Workspace-only route — logged-out users see "Usage is workspace-only"
- // wall in place of the console. The previous behavior (a hard redirect
- // to /login) was wrong per the v4 prototype: it dropped the
- // user out of context. The wall keeps them inside the app shell, leaves
- // the sidebar in its logged-out variant, and offers an explicit
- // "Explore capabilities" escape hatch.
- if (!isConnected) return ;
-
- // No header action. "Manage plan" linked to /settings?tab=billing, which is
- // hidden for the pilot — see the tab notes in app/(app)/settings/page.tsx.
- return (
-
-
-
-
-
-
- );
+export default function LegacyUsagePage() {
+ redirect("/home");
}
diff --git a/app/(app)/waitlist/page.tsx b/app/(app)/waitlist/page.tsx
index e72eb49..969e838 100644
--- a/app/(app)/waitlist/page.tsx
+++ b/app/(app)/waitlist/page.tsx
@@ -1,4 +1,3 @@
-import { Mail } from "lucide-react";
import { auth0 } from "@/lib/auth0";
import ConsolePageHeader from "@/components/console/ConsolePageHeader";
import SectionHeader from "@/components/console/SectionHeader";
@@ -13,7 +12,7 @@ export default async function WaitlistPage() {
return (
<>
-
+
-
,
+ document.body
);
}
diff --git a/components/console/CallsSection.tsx b/components/console/CallsSection.tsx
index 8b528d7..f82f942 100644
--- a/components/console/CallsSection.tsx
+++ b/components/console/CallsSection.tsx
@@ -4,46 +4,30 @@ import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { Loader2, Search, X } from "lucide-react";
import SectionHeader from "@/components/console/SectionHeader";
-import CallsTable from "@/components/console/CallsTable";
+import CallsTable, { modalityTag } from "@/components/console/CallsTable";
import CallDetailDrawer from "@/components/console/CallDetailDrawer";
import { useAuth } from "@/components/console/AuthContext";
import { useAccountRequests } from "@/lib/console/useAccountRequests";
import type { AccountActivityRow } from "@/lib/console/types";
-import { CAPABILITY_COLOR_OTHER } from "@/lib/console/usage-capability-display";
const EMPTY_ROWS: AccountActivityRow[] = [];
-/**
- * The call log is a *window*, not a page section that grows without limit.
- * The viewport is fixed and scrolls internally, with the column header
- * pinned, so the page stays a known length however many calls load.
- * The height is the same in every state — loading, empty, error, full — so
- * nothing below it jumps as data arrives.
- */
-const VIEWPORT = "h-[420px]";
+const PLACEHOLDER_CLASS =
+ "flex min-h-[180px] flex-col items-center justify-center gap-2 px-5 py-10 text-center";
/**
- * The per-call log, rendered underneath Spend by capability on /usage.
+ * The per-call log on /home. Its only control is search; a Live/Batch split
+ * was here once and came out: it's a pipeline-implementation distinction, not
+ * something a creator sorts their work by.
*
- * It shares the breakdown table's vocabulary — header, row rhythm, footer —
- * so the two read as one system. Its only control is search; a Live/Batch
- * split was here once and came out: it's a pipeline-implementation
- * distinction, not something a creator sorts their work by.
- *
- * Search is lifted to the caller so a capability row in the spend table can
- * drive it: clicking a capability filters this list instead of navigating.
* `/calls` still resolves — it redirects here, preserving `?request=`.
*/
export default function CallsSection({
query,
onQueryChange,
- colorByCapability,
}: {
query: string;
onQueryChange: (next: string) => void;
- /** Capability display name → series colour, from the breakdown table, so
- * each call's dot matches the capability row it rolls up into. */
- colorByCapability: ReadonlyMap;
}) {
const { isConnected } = useAuth();
const requests = useAccountRequests(isConnected);
@@ -74,7 +58,8 @@ export default function CallsSection({
(r) =>
r.id.toLowerCase().includes(q) ||
r.model.toLowerCase().includes(q) ||
- r.pipeline.toLowerCase().includes(q)
+ r.pipeline.toLowerCase().includes(q) ||
+ modalityTag(r.pipeline).includes(q)
)
: allRows;
return [...scoped].sort(
@@ -83,21 +68,15 @@ export default function CallsSection({
);
}, [allRows, query]);
- // The drawer closes back to /usage — the page it now lives on.
- const closeDrawer = () => router.push("/usage", { scroll: false });
+ // The drawer closes back to /home — the page it now lives on.
+ const closeDrawer = () => router.push("/home", { scroll: false });
+ const selectDrawerRow = (next: AccountActivityRow) => {
+ router.push(`/home?request=${next.id}`, { scroll: false });
+ };
- // Further pages load as the window is scrolled: a sentinel row sits under
- // the last loaded call and, when it scrolls into view, fetches the next
- // page in place. The window is a fixed-height frame with its own footer
- // outside the scroll, so there is no page footer for auto-loading to push
- // away — the usual reason to prefer a button — and the scrollbar already
- // expresses "there is more".
- //
- // While a search is active the sentinel becomes an explicit "Search older
- // calls" action instead. Search only covers loaded rows, so auto-loading
- // under a filter would page through the whole history looking for matches
- // the moment the few it found left the sentinel on screen.
- const windowRef = useRef(null);
+ // Further pages load as the page is scrolled. While a search is active the
+ // sentinel becomes an explicit action instead, because search only covers
+ // loaded rows.
const sentinelRef = useRef(null);
const [loadingMore, setLoadingMore] = useState(false);
const loadMore = async () => {
@@ -118,16 +97,13 @@ export default function CallsSection({
useEffect(() => {
if (!autoLoad) return;
- const root = windowRef.current;
const target = sentinelRef.current;
- if (!root || !target) return;
+ if (!target) return;
const observer = new IntersectionObserver(
(entries) => {
if (entries.some((e) => e.isIntersecting)) void loadMore();
},
- // Start the fetch a little before the sentinel is actually reached so
- // the next page is usually there by the time the scroll gets to it.
- { root, rootMargin: "0px 0px 120px 0px" }
+ { rootMargin: "0px 0px 360px 0px" }
);
observer.observe(target);
return () => observer.disconnect();
@@ -138,22 +114,16 @@ export default function CallsSection({
const loading = requests.status === "loading" || requests.status === "idle";
- /** Every non-table state fills the same window, so nothing below shifts. */
const Placeholder = ({ children }: { children: React.ReactNode }) => (
-
+ )}
+ >
+ )}
candidate.id === openCall.id)
+ ? allRows
+ : rows
+ }
open={!!openCall}
onClose={closeDrawer}
+ onSelectRow={selectDrawerRow}
/>
>
);
}
-
-function fmtCount(n: number): string {
- return n.toLocaleString("en-US");
-}
diff --git a/components/console/CallsTable.tsx b/components/console/CallsTable.tsx
index 59ce0d2..99f6d49 100644
--- a/components/console/CallsTable.tsx
+++ b/components/console/CallsTable.tsx
@@ -9,15 +9,17 @@ import type { AccountActivityRow } from "@/lib/console/types";
/**
* CallsTable — the single Linear-style call list, used by:
- * 1. the Calls section on /usage (formerly the standalone /calls view)
+ * 1. the History section on /home (formerly the standalone /calls view)
* 2. the app-detail Logs tab (filtered to one app)
*
- * Rows open the call inspector at `/usage?request=`, which is where the
+ * Rows open the call inspector at `/home?request=`, which is where the
* drawer now lives.
*
- * Row vocabulary (left → right):
+ * Full row vocabulary (left → right):
* 8px status dot · mono short id · model · pipeline pill · latency|duration ·
- * cost · via (signer) · relative time
+ * cost · via (signer) · relative time.
+ *
+ * History rows are quieter: relative time · model · modality pill · cost.
*
* The metric column adapts to the call's kind: a batch call reports **latency**
* (one request/response), a live call reports **duration** (a streaming
@@ -37,23 +39,80 @@ export interface CallsTableProps {
stickyHeader?: boolean;
/**
* `full` — dot · call · metric · cost · via · time (the app Logs tab).
- * `requests` — dot · call · cost · time. Signed-ticket rows carry neither a
- * latency nor a distinct signer, so on /usage those two columns rendered
- * "—" and a truncated "Livepeer A…" on every row: two of six columns
- * saying nothing.
+ * `requests` — call · cost · time. Signed-ticket rows carry neither a
+ * latency nor a distinct signer, so on /home those columns rendered "—"
+ * and a truncated "Livepeer A…" on every row.
*/
variant?: "full" | "requests";
/**
* Colour for the row's leading dot, keyed off the row. When given, the dot
- * encodes *which capability* the call hit — the same colour the Spend by
- * capability table uses — instead of the call's status. On /usage every
- * row is a signed ticket, which only exists for a completed, paid call,
- * so a status dot there could only ever be green and said nothing.
+ * encodes *which capability* the call hit instead of the call's status. On
+ * /home every row is a signed ticket, which only exists for a completed,
+ * paid call, so a status dot there could only ever be green and said
+ * nothing.
*/
rowColor?: (row: AccountActivityRow) => string;
className?: string;
}
+export function modalityTag(pipeline: string): string {
+ const normalized = pipeline.toLowerCase();
+ const exact: Record = {
+ "audio-to-text": "a2t",
+ "image-to-image": "i2i",
+ "image-to-video": "i2v",
+ language: "t2t",
+ llm: "t2t",
+ "live-transcoding": "v2v",
+ "live-video-to-video": "v2v",
+ "speech-to-text": "s2t",
+ "text-generation": "t2t",
+ "text-to-audio": "t2a",
+ "text-to-image": "t2i",
+ "text-to-speech": "t2s",
+ "text-to-video": "t2v",
+ transcoding: "v2v",
+ "video-understanding": "v2t",
+ "video-to-video": "v2v",
+ };
+ const mapped = exact[normalized];
+ if (mapped) return mapped;
+
+ const match = normalized.match(
+ /^(text|image|video|audio|speech|live)-to-(text|image|video|audio|speech)$/
+ );
+ if (match) {
+ const token = (part: string) =>
+ part === "text"
+ ? "t"
+ : part === "image"
+ ? "i"
+ : part === "video"
+ ? "v"
+ : part === "audio"
+ ? "a"
+ : "s";
+ return `${token(match[1]!)}2${token(match[2]!)}`;
+ }
+
+ return normalized
+ .split(/[-_./|:]+/)
+ .filter(Boolean)
+ .map((part) => part[0])
+ .join("")
+ .slice(0, 8);
+}
+
+function formatHistoryRelativeTime(iso: string): string {
+ const then = new Date(iso).getTime();
+ if (Number.isFinite(then)) {
+ const seconds = Math.round((Date.now() - then) / 1000);
+ if (seconds < 60) return "30s";
+ }
+
+ return formatRunRelativeTime(iso).replace(/ ago$/, "");
+}
+
export default function CallsTable({
rows,
showHeader = false,
@@ -70,8 +129,8 @@ export default function CallsTable({
// values, so each density preset is spelled out in full.
const cols = compact
? density === "cozy"
- ? "grid items-center gap-3 px-5 grid-cols-[minmax(0,1fr)_88px_88px]"
- : "grid items-center gap-3 px-4 grid-cols-[minmax(0,1fr)_76px_76px]"
+ ? "grid items-center gap-3 px-3 md:px-7 grid-cols-[minmax(0,1fr)_88px]"
+ : "grid items-center gap-3 px-4 grid-cols-[minmax(0,1fr)_76px]"
: density === "cozy"
? "grid items-center gap-3 px-5 grid-cols-[minmax(0,1fr)_80px_80px_80px_80px]"
: "grid items-center gap-3 px-4 grid-cols-[minmax(0,1fr)_70px_70px_70px_70px]";
@@ -107,11 +166,17 @@ export default function CallsTable({
{!compact && {metricLabel}}
Cost
{!compact && Via}
- Time
+ {!compact && Time}
)}
{rows.map((row, i) => {
const active = row.status === "active";
+ const pipelineLabel = compact
+ ? modalityTag(row.pipeline)
+ : row.pipeline;
+ const timeLabel = compact
+ ? formatHistoryRelativeTime(row.timestamp)
+ : formatRunRelativeTime(row.timestamp);
const tone =
row.status === "success"
? "bg-green-bright"
@@ -125,41 +190,53 @@ export default function CallsTable({
return (
0 ? "border-t border-hairline" : ""
+ !compact && i > 0 ? "border-t border-hairline" : ""
}`}
>
{/* Status dot sits inside the first cell, not in a column of its
- own, so the header label lines up with the breakdown table's
- on /usage (both start at the padding edge). */}
+ own, so the header label starts at the padding edge. */}
- {rowColor ? (
-
- ) : active ? (
- // Liveness pulse for an in-progress session (warm per the
- // liveness color convention).
-
- ) : (
-
+ {compact && (
+
+ {timeLabel}
+
+ )}
+ {!compact &&
+ (rowColor ? (
+
+ ) : active ? (
+ // Liveness pulse for an in-progress session (warm per the
+ // liveness color convention).
+
+ ) : (
+
+ ))}
+ {!compact && (
+
+ {row.id.slice(-7)}
+
)}
-
- {row.id.slice(-7)}
-
{row.model}
-
- {row.pipeline}
+
+ {pipelineLabel}
{showEnvironment && }
View usage
diff --git a/components/console/ConsolePageHeader.tsx b/components/console/ConsolePageHeader.tsx
index d5519e3..1ba5583 100644
--- a/components/console/ConsolePageHeader.tsx
+++ b/components/console/ConsolePageHeader.tsx
@@ -3,16 +3,12 @@
import type { ReactNode } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
-import type { LucideIcon } from "lucide-react";
import { useAuth } from "@/components/console/AuthContext";
import ScopeChip, { type PageScope } from "@/components/console/ScopeChip";
interface ConsolePageHeaderProps {
/** Primary page title — renders as the last (current) breadcrumb. */
title: string;
- /** Optional leading icon (lucide). Rendered to the left of the title at the
- * same size as the breadcrumb glyph in the design. */
- icon?: LucideIcon;
/** Optional one-line description rendered ABOVE the chrome bar in the page
* body when caller wants additional context. The chrome bar itself stays
* pure breadcrumb + actions. */
@@ -34,10 +30,9 @@ interface ConsolePageHeaderProps {
/**
* ConsolePageHeader — 44px chrome bar at the top of every console route.
*
- * Linear / Livepeer Console pattern: a slim breadcrumb bar with left-aligned
- * crumbs and right-aligned actions. The page title lives in the breadcrumb,
- * not as a hero. Information density comes from the *content*; this bar is
- * pure chrome and stays out of the way.
+ * Linear / Livepeer Console pattern: a slim title bar with left-aligned text
+ * and right-aligned actions. Information density comes from the content; this
+ * bar is pure chrome and stays out of the way.
*
* When the user is signed out (and not already on the `/login`
* auth route), a `Sign in` / `Sign up` pair is appended to the right
@@ -47,7 +42,6 @@ interface ConsolePageHeaderProps {
*/
export default function ConsolePageHeader({
title,
- icon: Icon,
description,
actions,
scope,
@@ -68,14 +62,7 @@ export default function ConsolePageHeader({
"flex h-[44px] shrink-0 items-center gap-3 border-b border-hairline bg-dark px-5"
}
>
-
- {Icon && (
-
- )}
+
{title}
diff --git a/components/console/ConsoleSearch.tsx b/components/console/ConsoleSearch.tsx
index 65d45b5..b537222 100644
--- a/components/console/ConsoleSearch.tsx
+++ b/components/console/ConsoleSearch.tsx
@@ -27,11 +27,6 @@ const SUGGESTIONS: SearchResult[] = [
subtitle: "Browse apps available on the network",
href: "/explore",
},
- {
- title: "Get your API key",
- subtitle: "Authenticate and start sending requests",
- href: "/settings?tab=tokens",
- },
{
title: "Transcode a stream",
subtitle: "Live transcoding on GPU infrastructure",
@@ -93,24 +88,8 @@ const ALL_RESULTS: SearchResult[] = [
subtitle: "Text-to-speech synthesis",
href: "/apps/kokoro-tts",
},
- { title: "Home", subtitle: "Console overview", href: "/home" },
- {
- title: "API Tokens",
- subtitle: "Manage your API keys",
- href: "/settings?tab=tokens",
- },
- {
- title: "Billing",
- subtitle: "Manage billing and payments",
- href: "/settings?tab=billing",
- },
- { title: "Usage", subtitle: "Request volume and spend", href: "/usage" },
- {
- title: "Account",
- subtitle: "Profile and security",
- href: "/settings?tab=account",
- },
- { title: "Settings", subtitle: "Account settings", href: "/settings" },
+ { title: "Install", subtitle: "Agent setup", href: "/install" },
+ { title: "Home", subtitle: "Request volume and spend", href: "/home" },
];
// ─── Component ───────────────────────────────────────────────────────────────
diff --git a/components/console/ConsoleSidebar.tsx b/components/console/ConsoleSidebar.tsx
index 6dc46c5..ab5839c 100644
--- a/components/console/ConsoleSidebar.tsx
+++ b/components/console/ConsoleSidebar.tsx
@@ -1,51 +1,20 @@
"use client";
-import { Suspense, useEffect, useState } from "react";
+import { useState } from "react";
import Link from "next/link";
-import { usePathname, useRouter, useSearchParams } from "next/navigation";
-import {
- ArrowUpRight,
- House,
- LayoutGrid,
- BarChart3,
- ChevronLeft,
- Globe,
- Menu,
- Palette,
- PanelLeftClose,
- PanelLeftOpen,
- Settings as SettingsIcon,
- User as UserIcon,
- type LucideIcon,
-} from "lucide-react";
-import {
- LivepeerWordmark,
- LivepeerSymbol,
-} from "@/components/design-system/LivepeerLogo";
-import { EXTERNAL_LINKS, PORTAL_NAV_ITEMS } from "@/lib/constants";
-import { useAuth } from "@/components/console/AuthContext";
+import { usePathname, useRouter } from "next/navigation";
+import { EllipsisVertical } from "lucide-react";
+import { AnimatePresence, motion, useReducedMotion } from "motion/react";
+import { LivepeerLockup } from "@/components/design-system/LivepeerLogo";
+import { PORTAL_NAV_ITEMS } from "@/lib/constants";
+import { useAuth, type ConsoleUser } from "@/components/console/AuthContext";
import Drawer from "@/components/design-system/Drawer";
-import Tooltip from "@/components/design-system/Tooltip";
import NavLink from "@/components/console/NavLink";
-import SidebarUsageCard from "@/components/console/SidebarUsageCard";
-import OrganizationMenu from "@/components/console/OrganizationMenu";
-import { APPS } from "@/lib/console/mock-data";
-import { formatRuns } from "@/lib/console/utils";
-const NAV_ICONS = {
- House,
- LayoutGrid,
- BarChart3,
- Globe,
- Settings: SettingsIcon,
-} as const;
-
-const COLLAPSED_KEY = "console.sidebar.collapsed";
+type PortalNavItem = (typeof PORTAL_NAV_ITEMS)[number];
function getNavActive(itemHref: string, pathname: string): boolean {
if (itemHref === "/home") return pathname === "/home";
- // Tab-deep links inherit active state from path only — Settings page tabs
- // already mark the current sub-tab visually inside their own TabStrip.
if (itemHref.includes("?")) {
const path = itemHref.split("?")[0];
return pathname.startsWith(path);
@@ -53,754 +22,515 @@ function getNavActive(itemHref: string, pathname: string): boolean {
return pathname.startsWith(itemHref);
}
-// ─── Site link ──────────────────────────────────────────────────────────────
-//
-// The one footer row the rail keeps: a way back to livepeer.org. It sits
-// below the usage card in every rail state (expanded, collapsed, settings,
-// signed out) and opens in a new tab — leaving for the marketing site is a
-// detour, not a sign-out, so the console stays where it was. Deliberately a
-// single row: the Docs / status strip this replaces grew because footers
-// accrete, and this one should not.
-
-function SiteLink({ collapsed, padX }: { collapsed: boolean; padX: string }) {
- const label = "livepeer.org";
- const link = (
-
+
+
+
+
+
+ );
+}
+
+function MobileMenuButton({
+ open,
+ label,
+ controls,
+ onClick,
+}: {
+ open: boolean;
+ label: string;
+ controls?: string;
+ onClick: () => void;
+}) {
+ return (
+
-
- {!collapsed && (
- <>
- {label}
-
- >
- )}
-
+
+
);
+}
+function MobileBrandLink({
+ href,
+ label,
+ onNavigate,
+}: {
+ href: string;
+ label: string;
+ onNavigate?: () => void;
+}) {
return (
-
- {collapsed ? (
-
-
- {link}
-
-
- ) : (
- link
- )}
-
+
+
+
);
}
-// ─── Sidebar content (shared between desktop + mobile drawer) ───────────────
+function UserAvatar({
+ user,
+ className,
+}: {
+ user: ConsoleUser;
+ className: string;
+}) {
+ if (user.avatarUrl) {
+ return (
+
+ );
+ }
-interface SidebarContentProps {
- collapsed: boolean;
- onToggleCollapsed?: () => void;
- /** Called when a nav item is clicked — used to close mobile drawer. */
- onNavigate?: () => void;
- /** Hides the collapse toggle (used inside the mobile drawer). */
- hideToggle?: boolean;
+ return (
+
+ {user.initials}
+
+ );
}
-// ─── Logged-out sidebar variant ─────────────────────────────────────────────
-//
-// Mirrors the Livepeer Dashboard v4 prototype's `loggedOut` Sidebar (see
-// `components.jsx`, the `if (loggedOut)` branch). Order top → bottom:
-//
-// 1. Brand row — wordmark links to / (no organization switcher)
-// 2. Search button (Cmd-K, same as signed-in variant)
-// 3. Public nav — Explore (count), Docs (external)
-// 4. ORGANIZATION eyebrow + locked nav: Home, Runs, Usage, API keys
-// 5. Spacer
-// 6. Free-tier promo card — "Get an API key" + "Sign in"
-// 7. Footer — Network nav, status row
-//
-// Locked items still navigate to their real routes; those routes render a
-// `SignInWall` instead of their content so the sidebar stays put. The promo
-// card replaces the SidebarUsageCard since there's no organization usage to
-// show; per the prototype the eyebrow is "Free tier" and the body sells the
-// 5-demo-runs hook with a single primary CTA.
-
-function SignedOutSidebarContent({
- collapsed,
- padX,
- onToggleCollapsed,
- hideToggle,
- onNavigate,
+function UserFooter({
+ user,
+ onSignOut,
}: {
- collapsed: boolean;
- padX: string;
- onToggleCollapsed?: () => void;
- hideToggle: boolean;
- onNavigate?: () => void;
+ user: ConsoleUser | null;
+ onSignOut: () => void;
}) {
- const pathname = usePathname();
- const router = useRouter();
- // Explore is canonically /explore, but signed-out visitors also see it as the
- // landing at /, so both count as "on Explore".
- const exploreActive = pathname === "/" || pathname.startsWith("/explore");
+ const [open, setOpen] = useState(false);
+ const reduceMotion = useReducedMotion();
+ const transition = reduceMotion
+ ? { duration: 0 }
+ : { duration: 0.2, ease: [0.22, 1, 0.36, 1] as const };
+
+ if (!user) return null;
+
+ const signOut = () => {
+ setOpen(false);
+ onSignOut();
+ };
return (
-
- {/* Brand row — wordmark links to / (the public landing) */}
-
-
- {/* Search button — same Cmd-K dispatch as the signed-in variant; copy
- tweaked to "Search apps…" since there's no organization to jump
- across. */}
-
-
- {/* Public nav — Explore + Stats. (Docs is not linked: docs.livepeer.org
- is orchestrator-only today and documents none of the agent surface.
- Restore the entry once the agent docs land.) */}
-
-
- {/* (The locked-Organization nav block previously rendered here — Home /
- Jobs / Usage / API keys with lock icons — has been removed. Logged-
- out users now go straight from public nav to the Free-tier promo.
- Discovery of those routes happens through the promo's "Get an API
- key" CTA + the sign-in walls that gate the routes themselves, not
- through teaser entries in the rail.) */}
-
- {/* Spacer pushes promo + footer to the bottom */}
-
-
- {/* Free-tier promo card — design spec `.side-promo` (yLXs… export).
- * - 14/14/12 asymmetric padding (a touch more breathing room at top)
- * - Radial glow anchored TOP-RIGHT using `--lp-soft` (green at 18%
- * alpha) at 70% opacity — gives the card a soft brand tint that
- * reads as "you can light this up by signing up"
- * - Eyebrow uses `--lp-bright` (green-bright) for accent identity
- * - Sub text is `--fg-4` (50% in dark) — dimmer than helper text
- * - Sign-in link is `--fg-3` (65%), font-medium with hover tint
- * Hidden when sidebar is collapsed (no useful 26px representation). */}
- {!collapsed && (
-
-
-
-
-
- Free tier
-
-
- 5 demo calls
-
- per app
-
-
- No credit card. Spin up in 30 seconds with an API key.
-
+
+
+ )}
+
+
+ >
);
}
-// ─── Settings rail ──────────────────────────────────────────────────────────
-//
-// Renders inline inside the signed-in `SidebarContent` when the user is on a
-// `/settings*` route: a back-arrow header that returns to `/home`, then the
-// settings destinations. The organization switcher and search above stay put;
-// the usage strip below stays put — only the main nav block swaps to this
-// rail.
-//
-// The pilot rail is FLAT — two entries, no group eyebrows. It used to carry an
-// `Organization` group (General / Members / Billing) and an `Account` group
-// (Profile / Notifications / Security). Members, Billing, Notifications and
-// Security are all hidden for the pilot, and General and Profile merged into
-// one page, which leaves two items: keeping two group headers over a
-// one-item-each split would be labelling for its own sake.
-//
-// Active item is determined by `?tab=` on the current path. Items whose
-// content isn't built yet still navigate (the route renders the closest
-// existing tab) so the rail's behavior is correct end-to-end.
-//
-// The tab is read with `useSearchParams`, which forces its caller into a
-// Suspense boundary: layouts never receive `searchParams`, so the value is
-// client-only during static prerender. The boundary is what keeps the server
-// and client renders in agreement — reading `window.location.search` behind a
-// `typeof window` check instead makes the server pick the default tab while
-// the client picks the real one, which is a hydration mismatch.
-
-const SETTINGS_RAIL_ITEMS: {
- id: string;
- label: string;
- icon: LucideIcon;
- meta?: string;
-}[] = [
- { id: "account", label: "Account", icon: UserIcon },
- { id: "appearance", label: "Appearance", icon: Palette },
-];
-
-function SettingsRailView({
- activeTab,
- padX,
+function SidebarBrand({
+ href,
+ label,
onNavigate,
}: {
- activeTab: string | null;
- padX: string;
+ href: string;
+ label: string;
onNavigate?: () => void;
}) {
- const router = useRouter();
-
return (
-
- {/* Back arrow + "Settings" header — returns to /home, mirroring
- the prototype's `setRoute('home')` on the back button. */}
- {
- router.push("/home");
- onNavigate?.();
- }}
- className="mb-1 flex h-[26px] items-center gap-1.5 rounded-[4px] px-2 text-[13px] text-fg-strong transition-colors hover:bg-hover hover:text-fg"
+
+
-
- Settings
-
-
-
- {SETTINGS_RAIL_ITEMS.map((it) => (
-
-
-
- ))}
-
+
+
);
}
-function SettingsRailNav({
- padX,
+function SidebarNav({
+ items,
+ label,
onNavigate,
}: {
- padX: string;
+ items: readonly PortalNavItem[];
+ label: string;
onNavigate?: () => void;
}) {
- // Default to "account" when on `/settings` with no tab param — Account is
- // the page /settings renders with no tab.
- const activeTab = useSearchParams().get("tab") ?? "account";
+ const pathname = usePathname();
return (
-
+
);
}
-function SettingsRail({
- padX,
- onNavigate,
-}: {
- padX: string;
- onNavigate?: () => void;
-}) {
- // The fallback renders the rail with nothing marked active rather than
- // guessing "account": a brief un-highlighted rail is honest, whereas a
- // defaulted one flashes the wrong tab before the real one resolves.
+function SignedOutSidebarContent({ onNavigate }: { onNavigate?: () => void }) {
+ const router = useRouter();
+ const publicItems = PORTAL_NAV_ITEMS.filter((i) => i.zone === "network");
+
return (
-
- }
- >
-
-
+
+
+
+
+
+
+
+
+
+
+
+ Free tier
+
+
+ 5 demo calls
+
+ per app
+
+
+ No credit card. Spin up in 30 seconds with an API key.
+
);
}
-function SidebarContent({
- collapsed,
- onToggleCollapsed,
- onNavigate,
- hideToggle = false,
-}: SidebarContentProps) {
- const pathname = usePathname();
+function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
const { isConnected, isLoading, user, disconnect } = useAuth();
- const padX = collapsed ? "px-2.5" : "px-3";
-
- // Nav — no global environment switcher. Environment is a per-page facet
- // (a filter on Apps / API keys, defaulting to "All environments"), not a
- // persistent global mode, so the sidebar is a flat task list:
- // • Primary — your organization resources (Home, Apps, API keys, Usage,
- // Settings — Settings sits last, under Usage).
- // • Footer — Docs.
- // The NETWORK group (Explore, Stats) is not rendered in the signed-in rail:
- // those routes still exist and stay linkable, they just aren't console
- // destinations. Their entries remain in PORTAL_NAV_ITEMS, filtered out here.
const primaryItems = PORTAL_NAV_ITEMS.filter((i) => i.zone !== "network");
- const renderNavItem = (item: (typeof PORTAL_NAV_ITEMS)[number]) => {
- const Icon = NAV_ICONS[item.icon];
- const active = getNavActive(item.href, pathname);
- // No nav item carries a right-aligned count any more — the one that did
- // was API keys, which the pilot doesn't provision.
- const meta: string | undefined = undefined;
- const itemKbd = "kbd" in item ? (item.kbd as string) : undefined;
- const itemSubmenu = "submenu" in item ? Boolean(item.submenu) : false;
- return (
-
-
-
- );
- };
-
- // Logged-out sidebar variant — per the v4 prototype's `loggedOut` Sidebar
- // (components.jsx:43). Brand wordmark in place of the organization switcher,
- // Explore + Docs as the only enabled routes, the organization block (Home /
- // Jobs / Usage / API keys) shown but locked, and a Free-tier promo block
- // replacing the organization usage card. We intentionally render this only
- // once auth state has hydrated to avoid a one-frame flash of the signed-in
- // chrome on cold load.
if (!isLoading && !isConnected) {
- return (
- ;
+ }
+
+ return (
+
- {/* Top: organization switcher (FB Flipbook ▾). Per the v6 prototype, the
- row is *just* the switcher — no "+ New" button, no collapse toggle.
- Organization-scoped actions live inside the dropdown instead. */}
-
-
- {/* Destinations. Home + your organization resources are the unlabeled
- primary list (scope shown per-page via the header chip). On /settings
- the whole list is replaced by the SettingsRail. */}
- {pathname.startsWith("/settings") ? (
-
+ Sign in
+
+
+
) : (
- <>
- {/* Primary — your organization resources. Environment is a per-page
- facet (a filter on Apps / Runs / API keys), not a global mode, so
- there's no environment switcher heading these. */}
-
- >
- )}
-
- {/* Spacer pushes footer to the bottom */}
-
-
- {/* Plan + usage card — between flex spacer and footer per the
- Livepeer Console design v2 (Apr 2026, `.side-usage`). The 8px
- bottom margin (pb-2) clears the footer's border-t hairline so the
- card doesn't visually sit on the divider. Hidden when collapsed
- (no useful 26px representation) AND when the user is inside the
- settings sub-experience — the organization usage strip would compete
- with the settings rail's own context. */}
- {isConnected && !collapsed && !pathname.startsWith("/settings") && (
-
-
+
+
)}
+
+ );
+}
- {/* Footer — the single livepeer.org row. Docs was removed from here
- because docs.livepeer.org is orchestrator-only and says nothing
- about the agent; restore it beside this row once agent docs land. */}
-
+function MobileHeader({
+ drawerOpen,
+ onOpen,
+}: {
+ drawerOpen: boolean;
+ onOpen: () => void;
+}) {
+ const { isConnected, isLoading } = useAuth();
+ const homeHref = !isLoading && !isConnected ? "/" : "/home";
+
+ return (
+