Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
162 changes: 162 additions & 0 deletions src/app/(operator)/CockpitHeader.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
"use client";

import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import { cn } from "@/shared/utils/cn";

const TABS: Array<{ href: string; label: string }> = [
{ href: "/approvals", label: "Inbox" },
{ href: "/calibration", label: "Calibration" },
{ href: "/activity", label: "Activity" },
];

interface StatusSnapshot {
gateCount: number;
sendCount: number;
matchPct: number | null;
ok: boolean;
}

/**
* Slim cockpit chrome — replaces Graze's DashboardLayout for the (operator)
* route group. One sticky row: brand, tabs (with active fill), live status
* pill, theme toggle. No left sidebar, no breadcrumbs, no page H1 noise.
*/
export default function CockpitHeader() {
const pathname = usePathname() ?? "";
const [status, setStatus] = useState<StatusSnapshot>({
gateCount: 0,
sendCount: 0,
matchPct: null,
ok: true,
});
const [refreshedAt, setRefreshedAt] = useState<Date | null>(null);

// Lightweight 5s poll of /api/operator/status. Server-side aggregates the
// three counts we want without going all the way back to gary-ui via SSR.
useEffect(() => {
let alive = true;
async function tick() {
try {
const res = await fetch("/api/operator/status", { cache: "no-store" });
if (!res.ok) {
if (alive) setStatus((s) => ({ ...s, ok: false }));
return;
}
const body = (await res.json()) as StatusSnapshot;
if (alive) {
setStatus({ ...body, ok: true });
setRefreshedAt(new Date());
}
} catch {
if (alive) setStatus((s) => ({ ...s, ok: false }));
}
}
void tick();
const id = setInterval(tick, 5000);
return () => {
alive = false;
clearInterval(id);
};
}, []);

return (
<header className="sticky top-0 z-30 border-b border-black/5 dark:border-white/5 bg-bg/95 backdrop-blur supports-[backdrop-filter]:bg-bg/75">
<div className="flex items-center gap-4 px-4 sm:px-6 lg:px-8 h-14">
<Link
href="/approvals"
className="flex items-center gap-2 text-sm font-semibold tracking-tight text-text-main shrink-0"
>
<span
aria-hidden="true"
className={cn(
"size-2 rounded-full",
status.ok ? "bg-emerald-500" : "bg-amber-500"
)}
title={status.ok ? "gary-ui online" : "gary-ui unreachable"}
/>
gary cockpit
</Link>

<nav
aria-label="Cockpit surfaces"
className="flex items-center gap-1 ml-2"
>
{TABS.map((tab) => {
const active = pathname === tab.href || pathname.startsWith(tab.href + "/");
const badge =
tab.href === "/approvals" && status.gateCount + status.sendCount > 0
? status.gateCount + status.sendCount
: null;
return (
<Link
key={tab.href}
href={tab.href}
className={cn(
"relative flex items-center gap-1.5 px-3 py-1.5 rounded-md text-sm font-medium transition-colors",
active
? "bg-black/5 dark:bg-white/8 text-text-main"
: "text-text-muted hover:text-text-main hover:bg-black/[0.03] dark:hover:bg-white/[0.04]"
)}
>
{tab.label}
{badge !== null && (
<span
className={cn(
"inline-flex items-center justify-center min-w-[18px] h-[18px] px-1 rounded-full text-[10px] font-semibold",
active
? "bg-accent text-white"
: "bg-black/10 dark:bg-white/15 text-text-main"
)}
>
{badge}
</span>
)}
</Link>
);
})}
</nav>

<div className="ml-auto flex items-center gap-3 text-xs text-text-muted">
{status.matchPct !== null && (
<span
className={cn(
"inline-flex items-baseline gap-1.5 px-2 py-1 rounded-md",
"border border-black/5 dark:border-white/5",
status.matchPct >= 75
? "text-emerald-600 dark:text-emerald-400"
: status.matchPct < 50
? "text-amber-600 dark:text-amber-400"
: "text-text-main"
)}
title="Recommended-match rate (lifetime)"
>
<span className="text-[10px] uppercase tracking-wider text-text-muted">match</span>
<span className="font-semibold">{status.matchPct}%</span>
</span>
)}
<RefreshIndicator at={refreshedAt} ok={status.ok} />
</div>
</div>
</header>
);
}

function RefreshIndicator({ at, ok }: { at: Date | null; ok: boolean }) {
const [, force] = useState(0);
useEffect(() => {
const id = setInterval(() => force((n) => n + 1), 1000);
return () => clearInterval(id);
}, []);

if (!ok) return <span className="text-amber-600 dark:text-amber-400">offline</span>;
if (!at) return <span className="opacity-60">…</span>;
const sec = Math.max(0, Math.round((Date.now() - at.getTime()) / 1000));
const label = sec < 5 ? "just now" : sec < 60 ? `${sec}s ago` : `${Math.round(sec / 60)}m ago`;
return (
<span className="opacity-70" aria-live="polite">
{label}
</span>
);
}
155 changes: 0 additions & 155 deletions src/app/(operator)/FrequencyStrip.tsx

This file was deleted.

46 changes: 0 additions & 46 deletions src/app/(operator)/OperatorNav.tsx

This file was deleted.

12 changes: 2 additions & 10 deletions src/app/(operator)/activity/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,22 +26,14 @@ export default async function ActivityPage() {
});

return (
<main className="flex flex-col gap-6 px-4 sm:px-6 lg:px-8 pb-12">
<header>
<h1 className="text-2xl font-semibold tracking-tight text-text-main">Activity</h1>
<p className="text-sm text-text-muted mt-1">
Decisions you've made and the work Gary did autonomously. Mark a decision as wrong to
feed the calibration loop.
</p>
</header>

<div className="flex flex-col gap-6 px-4 sm:px-6 lg:px-8 py-6">
{err && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 font-mono text-sm text-red-600 dark:text-red-400">
gary-ui unreachable: {err}
</div>
)}

<ActivityClient decisions={decisions} outputs={outputs} />
</main>
</div>
);
}
Loading