[codex] Switch dashboard sections to view routing

Switch dashboard navigation from long section scrolling to hash-backed views, preserving Usage as the default operational view and keeping mobile drawer navigation visible.
This commit is contained in:
Eyejoker
2026-04-26 22:03:52 +09:00
committed by GitHub
parent 646bc34372
commit 11ab18280c
2 changed files with 140 additions and 39 deletions

View File

@@ -27,9 +27,28 @@ interface DashboardState {
type UsageRow = DashboardOverview['usage']['rows'][number]; type UsageRow = DashboardOverview['usage']['rows'][number];
type RiskLevel = 'ok' | 'warn' | 'critical'; type RiskLevel = 'ok' | 'warn' | 'critical';
type UsageGroup = 'primary' | 'codex'; type UsageGroup = 'primary' | 'codex';
type DashboardView = 'usage' | 'health' | 'rooms' | 'scheduled';
const REFRESH_INTERVAL_MS = 15_000; const REFRESH_INTERVAL_MS = 15_000;
const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale'; const LOCALE_STORAGE_KEY = 'ejclaw.dashboard.locale';
const DEFAULT_VIEW: DashboardView = 'usage';
function isDashboardView(
value: string | null | undefined,
): value is DashboardView {
return (
value === 'usage' ||
value === 'health' ||
value === 'rooms' ||
value === 'scheduled'
);
}
function readViewFromHash(): DashboardView {
if (typeof window === 'undefined') return DEFAULT_VIEW;
const raw = window.location.hash.replace(/^#\/?/, '');
return isDashboardView(raw) ? raw : DEFAULT_VIEW;
}
function readInitialLocale(): Locale { function readInitialLocale(): Locale {
const stored = const stored =
@@ -110,10 +129,10 @@ function queueLabel(
function navItems(t: Messages) { function navItems(t: Messages) {
return [ return [
{ href: '#overview', label: t.nav.health }, { href: '#/usage', label: t.nav.usage, view: 'usage' as const },
{ href: '#usage', label: t.nav.usage }, { href: '#/health', label: t.nav.health, view: 'health' as const },
{ href: '#rooms', label: t.nav.rooms }, { href: '#/rooms', label: t.nav.rooms, view: 'rooms' as const },
{ href: '#work', label: t.nav.scheduled }, { href: '#/scheduled', label: t.nav.scheduled, view: 'scheduled' as const },
]; ];
} }
@@ -170,15 +189,19 @@ function LoadingSkeleton({ t }: { t: Messages }) {
} }
function SideRail({ function SideRail({
activeView,
lastRefreshed, lastRefreshed,
locale, locale,
onNavigate,
onLocaleChange, onLocaleChange,
onRefresh, onRefresh,
refreshing, refreshing,
t, t,
}: { }: {
activeView: DashboardView;
lastRefreshed: string | null; lastRefreshed: string | null;
locale: Locale; locale: Locale;
onNavigate: (view: DashboardView) => void;
onLocaleChange: (locale: Locale) => void; onLocaleChange: (locale: Locale) => void;
onRefresh: () => void; onRefresh: () => void;
refreshing: boolean; refreshing: boolean;
@@ -192,7 +215,13 @@ function SideRail({
</div> </div>
<nav aria-label={t.nav.drawerNavAria}> <nav aria-label={t.nav.drawerNavAria}>
{navItems(t).map((item) => ( {navItems(t).map((item) => (
<a href={item.href} key={item.href}> <a
aria-current={activeView === item.view ? 'page' : undefined}
className={activeView === item.view ? 'is-active' : undefined}
href={item.href}
key={item.href}
onClick={() => onNavigate(item.view)}
>
{item.label} {item.label}
</a> </a>
))} ))}
@@ -216,26 +245,33 @@ function SideRail({
} }
function SectionNav({ function SectionNav({
activeView,
drawerOpen, drawerOpen,
lastRefreshed, lastRefreshed,
locale, locale,
onCloseDrawer, onCloseDrawer,
onLocaleChange, onLocaleChange,
onNavigate,
onOpenDrawer, onOpenDrawer,
refreshing, refreshing,
onRefresh, onRefresh,
t, t,
}: { }: {
activeView: DashboardView;
drawerOpen: boolean; drawerOpen: boolean;
lastRefreshed: string | null; lastRefreshed: string | null;
locale: Locale; locale: Locale;
onCloseDrawer: () => void; onCloseDrawer: () => void;
onLocaleChange: (locale: Locale) => void; onLocaleChange: (locale: Locale) => void;
onNavigate: (view: DashboardView) => void;
onOpenDrawer: () => void; onOpenDrawer: () => void;
refreshing: boolean; refreshing: boolean;
onRefresh: () => void; onRefresh: () => void;
t: Messages; t: Messages;
}) { }) {
const activeLabel =
navItems(t).find((item) => item.view === activeView)?.label ?? t.nav.usage;
return ( return (
<> <>
<nav className="section-nav" aria-label={t.nav.aria}> <nav className="section-nav" aria-label={t.nav.aria}>
@@ -251,7 +287,7 @@ function SectionNav({
<span /> <span />
<span /> <span />
</button> </button>
<strong className="topbar-label">{t.nav.usage}</strong> <strong className="topbar-label">{activeLabel}</strong>
<button <button
aria-busy={refreshing} aria-busy={refreshing}
aria-label={refreshing ? t.actions.refreshing : t.actions.refresh} aria-label={refreshing ? t.actions.refreshing : t.actions.refresh}
@@ -297,7 +333,16 @@ function SectionNav({
</div> </div>
<nav aria-label={t.nav.drawerNavAria}> <nav aria-label={t.nav.drawerNavAria}>
{navItems(t).map((item) => ( {navItems(t).map((item) => (
<a href={item.href} key={item.href} onClick={onCloseDrawer}> <a
aria-current={activeView === item.view ? 'page' : undefined}
className={activeView === item.view ? 'is-active' : undefined}
href={item.href}
key={item.href}
onClick={() => {
onNavigate(item.view);
onCloseDrawer();
}}
>
{item.label} {item.label}
</a> </a>
))} ))}
@@ -761,6 +806,7 @@ function App() {
const [refreshing, setRefreshing] = useState(false); const [refreshing, setRefreshing] = useState(false);
const [lastRefreshed, setLastRefreshed] = useState<string | null>(null); const [lastRefreshed, setLastRefreshed] = useState<string | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [activeView, setActiveView] = useState<DashboardView>(readViewFromHash);
const [locale, setLocale] = useState<Locale>(readInitialLocale); const [locale, setLocale] = useState<Locale>(readInitialLocale);
const t = messages[locale]; const t = messages[locale];
@@ -769,6 +815,13 @@ function App() {
window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale); window.localStorage.setItem(LOCALE_STORAGE_KEY, nextLocale);
} }
function navigateToView(view: DashboardView) {
setActiveView(view);
if (window.location.hash !== `#/${view}`) {
window.location.hash = `/${view}`;
}
}
async function refresh(showSpinner = false) { async function refresh(showSpinner = false) {
if (showSpinner) setRefreshing(true); if (showSpinner) setRefreshing(true);
try { try {
@@ -788,6 +841,16 @@ function App() {
document.documentElement.lang = localeTags[locale]; document.documentElement.lang = localeTags[locale];
}, [locale]); }, [locale]);
useEffect(() => {
function handleHashChange() {
setActiveView(readViewFromHash());
}
handleHashChange();
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, []);
useEffect(() => { useEffect(() => {
void refresh(); void refresh();
const id = window.setInterval(() => { const id = window.setInterval(() => {
@@ -814,8 +877,10 @@ function App() {
return ( return (
<div className="shell"> <div className="shell">
<SideRail <SideRail
activeView={activeView}
lastRefreshed={lastRefreshed} lastRefreshed={lastRefreshed}
locale={locale} locale={locale}
onNavigate={navigateToView}
onLocaleChange={setDashboardLocale} onLocaleChange={setDashboardLocale}
onRefresh={() => void refresh(true)} onRefresh={() => void refresh(true)}
refreshing={refreshing} refreshing={refreshing}
@@ -823,11 +888,13 @@ function App() {
/> />
<main className="dashboard-content"> <main className="dashboard-content">
<SectionNav <SectionNav
activeView={activeView}
drawerOpen={drawerOpen} drawerOpen={drawerOpen}
lastRefreshed={lastRefreshed} lastRefreshed={lastRefreshed}
locale={locale} locale={locale}
onCloseDrawer={() => setDrawerOpen(false)} onCloseDrawer={() => setDrawerOpen(false)}
onLocaleChange={setDashboardLocale} onLocaleChange={setDashboardLocale}
onNavigate={navigateToView}
onOpenDrawer={() => setDrawerOpen(true)} onOpenDrawer={() => setDrawerOpen(true)}
onRefresh={() => void refresh(true)} onRefresh={() => void refresh(true)}
refreshing={refreshing} refreshing={refreshing}
@@ -846,41 +913,50 @@ function App() {
) : null} ) : null}
{data ? ( {data ? (
<> <div className={`view-stack view-${activeView}`}>
<section className="panel usage-first" id="usage"> {activeView === 'usage' ? (
<div className="panel-title"> <>
<h2>{t.panels.usage}</h2> <section className="panel usage-first" id="usage">
<span>{t.panels.usageWindow}</span> <div className="panel-title">
</div> <h2>{t.panels.usage}</h2>
<UsagePanel locale={locale} overview={data.overview} t={t} /> <span>{t.panels.usageWindow}</span>
</section> </div>
<UsagePanel locale={locale} overview={data.overview} t={t} />
</section>
<ControlRail data={data} t={t} />
</>
) : null}
<ControlRail data={data} t={t} /> {activeView === 'health' ? (
<section className="panel view-panel" id="health">
<div className="panel-title">
<h2>{t.panels.health}</h2>
<span>{t.panels.heartbeat}</span>
</div>
<ServicePanel locale={locale} overview={data.overview} t={t} />
</section>
) : null}
<section className="panel" id="agents"> {activeView === 'rooms' ? (
<div className="panel-title"> <section className="panel view-panel" id="rooms">
<h2>{t.panels.health}</h2> <div className="panel-title">
<span>{t.panels.heartbeat}</span> <h2>{t.panels.rooms}</h2>
</div> <span>{t.panels.queue}</span>
<ServicePanel locale={locale} overview={data.overview} t={t} /> </div>
</section> <RoomPanel snapshots={data.snapshots} t={t} />
</section>
) : null}
<section className="panel" id="rooms"> {activeView === 'scheduled' ? (
<div className="panel-title"> <section className="panel view-panel" id="scheduled">
<h2>{t.panels.rooms}</h2> <div className="panel-title">
<span>{t.panels.queue}</span> <h2>{t.panels.scheduled}</h2>
</div> <span>{t.panels.promptPreviews}</span>
<RoomPanel snapshots={data.snapshots} t={t} /> </div>
</section> <TaskPanel locale={locale} tasks={data.tasks} t={t} />
</section>
<section className="panel" id="work"> ) : null}
<div className="panel-title"> </div>
<h2>{t.panels.scheduled}</h2>
<span>{t.panels.promptPreviews}</span>
</div>
<TaskPanel locale={locale} tasks={data.tasks} t={t} />
</section>
</>
) : null} ) : null}
</main> </main>
</div> </div>

View File

@@ -70,8 +70,20 @@ table {
.dashboard-content { .dashboard-content {
display: grid; display: grid;
align-content: start;
gap: 12px; gap: 12px;
min-width: 0; min-width: 0;
min-height: calc(100vh - 36px);
}
.view-stack {
display: grid;
gap: 12px;
min-width: 0;
}
.view-panel {
min-height: calc(100vh - 36px);
} }
.shell::before { .shell::before {
@@ -248,6 +260,15 @@ button:disabled {
background: rgba(191, 95, 44, 0.12); background: rgba(191, 95, 44, 0.12);
} }
.side-rail nav a.is-active,
.side-rail nav a[aria-current='page'],
.nav-drawer nav a.is-active,
.nav-drawer nav a[aria-current='page'] {
color: #fffaf0;
background: linear-gradient(135deg, #2b3726, #1c211c);
box-shadow: inset 0 0 0 1px rgba(255, 250, 240, 0.18);
}
.side-refresh { .side-refresh {
width: 100%; width: 100%;
min-height: 44px; min-height: 44px;
@@ -1024,6 +1045,10 @@ progress::-moz-progress-bar {
display: none; display: none;
} }
.section-nav .menu-button span {
display: block;
}
.section-nav button { .section-nav button {
min-width: 0; min-width: 0;
color: transparent; color: transparent;