Split the web UI into a page per section

The dashboard was a single route that swapped section components via a
`section` state field. Convert it to the App Router's multi-page model so
each left-nav selection is its own route (/, /repositories, /agents,
/schedules, /approvals, /servers, /activity, /shared, /login), making the
pages modular and independently updatable.

- Move the token gate + store provider + sidebar into a persistent frame
  (AppFrame + Shell) rendered by the root layout, so auth, the polling
  loop, and shared state survive client-side navigation.
- Sidebar items are now <Link> routes; the active item and the store's
  polled section are derived from the URL (lib/nav).
- Each section gets an app/<section>/page.tsx; Runs stays at root and keeps
  its full-height split layout, the rest render in the shared scroll frame.
- Emit per-route index.html (trailingSlash) so the FastAPI StaticFiles
  mount serves clean slash-terminated URLs with no SPA rewrite.
- Regenerate the bundled static export.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PsAeGVadULRzPhV2PRDttM
This commit is contained in:
Claude
2026-07-22 19:34:33 +00:00
parent c600ad481e
commit 5d8c62450c
58 changed files with 410 additions and 174 deletions
+13
View File
@@ -0,0 +1,13 @@
/* Activity page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { ActivitySection } from "@/components/sections/ActivitySection";
export default function ActivityPage() {
return (
<div className="main-scroll">
<ActivitySection />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
/* Agents page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { AgentsSection } from "@/components/sections/AgentsSection";
export default function AgentsPage() {
return (
<div className="main-scroll">
<AgentsSection />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
/* Approvals page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { ApprovalsSection } from "@/components/sections/ApprovalsSection";
export default function ApprovalsPage() {
return (
<div className="main-scroll">
<ApprovalsSection />
</div>
);
}
+6 -1
View File
@@ -1,15 +1,20 @@
import type { Metadata } from "next";
import "./globals.css";
import { AppFrame } from "@/components/AppFrame";
export const metadata: Metadata = {
title: "Handler · Claude Activity",
description: "Monitor and manage Claude Code agents across projects.",
};
/* The frame (token gate, store/polling, sidebar) wraps every route and persists across
* navigation; each page under app/ supplies only its own section content. */
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<body>{children}</body>
<body>
<AppFrame>{children}</AppFrame>
</body>
</html>
);
}
+13
View File
@@ -0,0 +1,13 @@
/* Claude Login page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { LoginSection } from "@/components/sections/LoginSection";
export default function LoginPage() {
return (
<div className="main-scroll">
<LoginSection />
</div>
);
}
+6 -45
View File
@@ -1,49 +1,10 @@
/* Root page: token gate → dashboard. Client-only; the exported HTML is a shell and every
* byte of data is fetched by the browser from the authed API after the token is supplied. */
/* Runs — the inbox and the app's landing page (root route). Rendered inside the shared
* shell from the root layout; unlike the scrollable sections it owns its own split layout,
* so it renders directly into main with no outer scroll wrapper. */
"use client";
import { useCallback, useEffect, useState } from "react";
import { DashboardProvider } from "@/components/store";
import { Dashboard } from "@/components/Dashboard";
import { TokenGate } from "@/components/TokenGate";
import { RunsSection } from "@/components/sections/RunsSection";
const TOKEN_KEY = "handler_token";
export default function Home() {
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState("");
// Read the stored token after mount (localStorage is client-only).
useEffect(() => {
const stored = window.localStorage.getItem(TOKEN_KEY);
if (stored) setToken(stored);
}, []);
const saveToken = useCallback((t: string) => {
window.localStorage.setItem(TOKEN_KEY, t);
setError("");
setToken(t);
}, []);
const signOut = useCallback(() => {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
}, []);
// A 401 from any call clears the token and re-prompts with an error.
const onUnauthorized = useCallback(() => {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
setError("Invalid token — please try again.");
}, []);
if (!token) {
return <TokenGate error={error} onSubmit={saveToken} />;
}
return (
<DashboardProvider token={token} onUnauthorized={onUnauthorized}>
<Dashboard onSignOut={signOut} />
</DashboardProvider>
);
export default function RunsPage() {
return <RunsSection />;
}
+13
View File
@@ -0,0 +1,13 @@
/* Repositories page. The shell (sidebar, banners, store) comes from the root layout;
* this route contributes only its section, in the shared scroll frame. */
"use client";
import { RepositoriesSection } from "@/components/sections/RepositoriesSection";
export default function RepositoriesPage() {
return (
<div className="main-scroll">
<RepositoriesSection />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
/* Schedules page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { SchedulesSection } from "@/components/sections/SchedulesSection";
export default function SchedulesPage() {
return (
<div className="main-scroll">
<SchedulesSection />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
/* Git Servers page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { GitServersSection } from "@/components/sections/GitServersSection";
export default function GitServersPage() {
return (
<div className="main-scroll">
<GitServersSection />
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
/* Shared page. The shell (sidebar, banners, store) comes from the root layout; this
* route contributes only its section, in the shared scroll frame. */
"use client";
import { SharedSection } from "@/components/sections/SharedSection";
export default function SharedPage() {
return (
<div className="main-scroll">
<SharedSection />
</div>
);
}
+57
View File
@@ -0,0 +1,57 @@
/* Auth frame: token gate → shell. Lives in the root layout so it wraps every page and
* persists across client-side navigation. Client-only; the exported HTML is a shell and
* every byte of data is fetched by the browser from the authed API after the token is
* supplied. A 401 from any call clears the token and re-prompts with an error. */
"use client";
import { useCallback, useEffect, useState } from "react";
import { usePathname } from "next/navigation";
import { DashboardProvider } from "@/components/store";
import { Shell } from "@/components/Shell";
import { TokenGate } from "@/components/TokenGate";
import { sectionFromPath } from "@/lib/nav";
const TOKEN_KEY = "handler_token";
export function AppFrame({ children }: { children: React.ReactNode }) {
const [token, setToken] = useState<string | null>(null);
const [error, setError] = useState("");
const pathname = usePathname();
// Read the stored token after mount (localStorage is client-only).
useEffect(() => {
const stored = window.localStorage.getItem(TOKEN_KEY);
if (stored) setToken(stored);
}, []);
const saveToken = useCallback((t: string) => {
window.localStorage.setItem(TOKEN_KEY, t);
setError("");
setToken(t);
}, []);
const signOut = useCallback(() => {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
}, []);
const onUnauthorized = useCallback(() => {
window.localStorage.removeItem(TOKEN_KEY);
setToken(null);
setError("Invalid token — please try again.");
}, []);
if (!token) {
return <TokenGate error={error} onSubmit={saveToken} />;
}
return (
<DashboardProvider
token={token}
onUnauthorized={onUnauthorized}
initialSection={sectionFromPath(pathname)}
>
<Shell onSignOut={signOut}>{children}</Shell>
</DashboardProvider>
);
}
-114
View File
@@ -1,114 +0,0 @@
/* The Control Center shell: a left nav (Runs / Repositories / Agents / Approvals / Git
* Servers / Activity / Shared) and the active section on the right, matching the design's
* hub layout. Command feedback and load errors surface as banners at the top of main. */
"use client";
import { useDashboard, type Section } from "@/components/store";
import { RunsSection } from "@/components/sections/RunsSection";
import { RepositoriesSection } from "@/components/sections/RepositoriesSection";
import { AgentsSection } from "@/components/sections/AgentsSection";
import { SchedulesSection } from "@/components/sections/SchedulesSection";
import { ApprovalsSection } from "@/components/sections/ApprovalsSection";
import { GitServersSection } from "@/components/sections/GitServersSection";
import { ActivitySection } from "@/components/sections/ActivitySection";
import { SharedSection } from "@/components/sections/SharedSection";
import { LoginSection } from "@/components/sections/LoginSection";
interface NavDef {
key: Section;
label: string;
count: (s: ReturnType<typeof useDashboard>) => number;
accent?: (s: ReturnType<typeof useDashboard>) => boolean;
}
const NAV: NavDef[] = [
{
key: "runs",
label: "Runs",
count: (s) => s.agents.length,
accent: (s) => s.agents.some((a) => a.status === "paused_for_input"),
},
{ key: "repositories", label: "Repositories", count: (s) => s.projects.length },
{ key: "agents", label: "Agents", count: (s) => s.agents.length },
{ key: "schedules", label: "Schedules", count: (s) => s.schedules.length },
{ key: "approvals", label: "Approvals", count: (s) => s.approvals.length },
{ key: "servers", label: "Git Servers", count: (s) => s.hosts.length },
{ key: "activity", label: "Activity", count: (s) => s.commands.length },
{ key: "shared", label: "Shared", count: (s) => s.shared.context.length },
{
key: "login",
label: "Claude Login",
count: () => 0,
// Draw the eye to it until Claude is logged in on the host this session.
accent: (s) => s.claudeLogin.status !== "done",
},
];
export function Dashboard({ onSignOut }: { onSignOut: () => void }) {
const s = useDashboard();
return (
<div className="app">
<aside className="sidebar">
<div className="brand">
<span className="logo" />
Claude Monitor
</div>
{NAV.map((n) => {
const c = n.count(s);
const isAccent = n.accent?.(s) ?? false;
return (
<button
key={n.key}
className={`nav-item${s.section === n.key ? " active" : ""}`}
onClick={() => s.setSection(n.key)}
>
<span>{n.label}</span>
<span className="count" style={isAccent ? { color: "var(--lw-warning-fg)" } : undefined}>
{c || ""}
</span>
</button>
);
})}
<div className="sidebar-spacer" />
<div className="sidebar-foot">
<button className="nav-item" onClick={s.refresh} title="Refresh now">
<span>Refresh</span>
<span className="count"></span>
</button>
<button className="nav-item" onClick={onSignOut} title="Sign out / change token">
<span>Sign out</span>
</button>
</div>
</aside>
<main className="main">
{s.cmd.text && (
<p className={`banner ${s.cmd.error ? "err" : "ok"}`} style={{ marginTop: 16 }}>
{s.cmd.text}
</p>
)}
{s.lastError && (
<p className="banner err" style={{ marginTop: 12 }}>
{s.lastError}
</p>
)}
{s.section === "runs" ? (
<RunsSection />
) : (
<div className="main-scroll">
{s.section === "repositories" && <RepositoriesSection />}
{s.section === "agents" && <AgentsSection />}
{s.section === "schedules" && <SchedulesSection />}
{s.section === "approvals" && <ApprovalsSection />}
{s.section === "servers" && <GitServersSection />}
{s.section === "activity" && <ActivitySection />}
{s.section === "shared" && <SharedSection />}
{s.section === "login" && <LoginSection />}
</div>
)}
</main>
</div>
);
}
+101
View File
@@ -0,0 +1,101 @@
/* The Control Center shell: a left nav (Runs / Repositories / Agents / Approvals / Git
* Servers / Activity / Shared / Claude Login) and the active route's page on the right,
* matching the design's hub layout. Each nav item is a real route, so pages are modular
* and independently loadable; this shell lives in the root layout and persists across
* navigation, keeping the store, polling loop, and auth alive between pages. Command
* feedback and load errors surface as banners at the top of main. */
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect } from "react";
import { useDashboard } from "@/components/store";
import { NAV_ROUTES, sectionFromPath } from "@/lib/nav";
import type { Section } from "@/components/store";
type Store = ReturnType<typeof useDashboard>;
/* Per-section nav badge: the count on the right and whether it should draw the eye. Keyed
* by section so the route table in lib/nav stays free of store-shaped logic. */
const BADGES: Partial<Record<Section, { count: (s: Store) => number; accent?: (s: Store) => boolean }>> = {
runs: {
count: (s) => s.agents.length,
accent: (s) => s.agents.some((a) => a.status === "paused_for_input"),
},
repositories: { count: (s) => s.projects.length },
agents: { count: (s) => s.agents.length },
schedules: { count: (s) => s.schedules.length },
approvals: { count: (s) => s.approvals.length },
servers: { count: (s) => s.hosts.length },
activity: { count: (s) => s.commands.length },
shared: { count: (s) => s.shared.context.length },
login: {
count: () => 0,
// Draw the eye to it until Claude is logged in on the host this session.
accent: (s) => s.claudeLogin.status !== "done",
},
};
export function Shell({ onSignOut, children }: { onSignOut: () => void; children: React.ReactNode }) {
const s = useDashboard();
const pathname = usePathname();
const active = sectionFromPath(pathname);
// Tell the store which section is on screen so its polling loop fetches the right data.
const { setSection } = s;
useEffect(() => {
setSection(active);
}, [active, setSection]);
return (
<div className="app">
<aside className="sidebar">
<div className="brand">
<span className="logo" />
Claude Monitor
</div>
{NAV_ROUTES.map((n) => {
const badge = BADGES[n.key];
const c = badge?.count(s) ?? 0;
const isAccent = badge?.accent?.(s) ?? false;
return (
<Link
key={n.key}
href={n.href}
className={`nav-item${active === n.key ? " active" : ""}`}
>
<span>{n.label}</span>
<span className="count" style={isAccent ? { color: "var(--lw-warning-fg)" } : undefined}>
{c || ""}
</span>
</Link>
);
})}
<div className="sidebar-spacer" />
<div className="sidebar-foot">
<button className="nav-item" onClick={s.refresh} title="Refresh now">
<span>Refresh</span>
<span className="count"></span>
</button>
<button className="nav-item" onClick={onSignOut} title="Sign out / change token">
<span>Sign out</span>
</button>
</div>
</aside>
<main className="main">
{s.cmd.text && (
<p className={`banner ${s.cmd.error ? "err" : "ok"}`} style={{ marginTop: 16 }}>
{s.cmd.text}
</p>
)}
{s.lastError && (
<p className="banner err" style={{ marginTop: 12 }}>
{s.lastError}
</p>
)}
{children}
</main>
</div>
);
}
+6 -1
View File
@@ -189,17 +189,22 @@ export function useDashboard(): StoreValue {
export function DashboardProvider({
token,
onUnauthorized,
initialSection = "runs",
children,
}: {
token: string;
onUnauthorized: () => void;
/* Which section is on screen at mount, derived from the URL by the auth frame. The
* provider lives in the root layout and mounts once, so this only seeds the first
* poll; later navigation updates the section through setSection. */
initialSection?: Section;
children: ReactNode;
}) {
const client = useMemo(() => createClient(token, onUnauthorized), [token, onUnauthorized]);
const clientRef = useRef(client);
clientRef.current = client;
const [section, setSectionRaw] = useState<Section>("runs");
const [section, setSectionRaw] = useState<Section>(initialSection);
const [projects, setProjects] = useState<Project[]>([]);
const [agents, setAgents] = useState<RunAgent[]>([]);
const [selectedProjectId, setSelectedProjectId] = useState<string>("");
+29
View File
@@ -0,0 +1,29 @@
/* The left-nav map: one entry per section, each its own route/page. This is the single
* source of truth shared by the sidebar (which renders the links) and the auth frame
* (which seeds the store's active section from the URL on first load). Runs is the root. */
import type { Section } from "@/components/store";
export interface NavRoute {
key: Section;
href: string;
label: string;
}
export const NAV_ROUTES: NavRoute[] = [
{ key: "runs", href: "/", label: "Runs" },
{ key: "repositories", href: "/repositories", label: "Repositories" },
{ key: "agents", href: "/agents", label: "Agents" },
{ key: "schedules", href: "/schedules", label: "Schedules" },
{ key: "approvals", href: "/approvals", label: "Approvals" },
{ key: "servers", href: "/servers", label: "Git Servers" },
{ key: "activity", href: "/activity", label: "Activity" },
{ key: "shared", href: "/shared", label: "Shared" },
{ key: "login", href: "/login", label: "Claude Login" },
];
/* Map a browser path back to its section key. Trailing slashes (Next emits them under
* `trailingSlash: true`) are normalized away; anything unrecognized falls back to Runs. */
export function sectionFromPath(pathname: string): Section {
const clean = pathname.replace(/\/+$/, "") || "/";
return NAV_ROUTES.find((r) => r.href === clean)?.key ?? "runs";
}
+4
View File
@@ -5,6 +5,10 @@ const nextConfig = {
// authed API with relative paths, so there is no separate frontend server to run.
output: "export",
reactStrictMode: true,
// Emit each route as `<route>/index.html` (not `<route>.html`) so the FastAPI static
// mount — Starlette's StaticFiles(html=True) — serves clean, slash-terminated URLs like
// `/repositories/` straight from disk, with no SPA rewrite or per-route server config.
trailingSlash: true,
// The export is served from disk with no image optimizer behind it.
images: { unoptimized: true },
};