feat(console): rombak penuh dashboard layout jadi Event Horizon

Layout baru single-screen ops console:
- TopBar 48px (brand monogram, guild, ws status, clock UTC/local, focus mode)
- LeftRail 80px (icon+label nav, signal accent bar, no boxes)
- Hero strip (display headline + mono counters: clean/warned/flagged/ratio)
- EventFeed (vertical timeline of message events, severity dots, no cards)
- NowMarker (inline pulse + cluster band insert per 10 events / 30s)
- RightRail 320px collapsible (ai verdicts / voice / mod queue / socket)
- DashCommandLine bottom 44px (mono prompt, '/' focuses, /mute /jump /find /clear)

Replace Spine + StatusBar lama untuk /dashboard via pathname branch di
(dashboard)/layout.tsx — route lain (messages/voice/media/dll) tetap
pakai ClassicShell, tidak ter-regress.

SSR seed tetap lewat page.tsx (server fetch stats + activity), synthetic
seed events dari daily buckets sampai WS message_created kick in.

WS event mapper: severity di-derive dari ai_status + ai_severity,
excerpt dipotong 140 char, channel tail 4 char.

No card chrome, no shadow, no bento grid, no tab panels.
This commit is contained in:
asepharyana
2026-08-15 16:21:45 +07:00
parent 6c9a91dad4
commit 84757bdcf4
9 changed files with 1346 additions and 239 deletions
@@ -0,0 +1,182 @@
"use client";
/**
* DashCommandLine — sticky bottom prompt for ops actions.
*
* The signature element of the new dashboard. Pure mono input; parses a
* slash-prefixed verb and dispatches to existing APIs or client-side
* actions. Autocomplete is intentionally light (suggestions render in
* monospace below the input).
*/
import {
type FormEvent,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
type CommandVerb = "mute" | "jump" | "find" | "clear";
interface CommandResult {
ok: boolean;
message: string;
}
const VERBS: CommandVerb[] = ["mute", "jump", "find", "clear"];
interface DashCommandLineProps {
onCommand?: (verb: CommandVerb, args: string) => CommandResult | undefined;
placeholder?: string;
}
export function DashCommandLine({
onCommand,
placeholder = "type a command — /mute @user 10m, /jump #channel, /find text, /clear",
}: DashCommandLineProps) {
const [value, setValue] = useState("");
const [history, setHistory] = useState<string[]>([]);
const [_historyIdx, setHistoryIdx] = useState<number>(-1);
const [result, setResult] = useState<CommandResult | null>(null);
const inputRef = useRef<HTMLInputElement | null>(null);
// Global "/" focuses the command line (skip when typing in another input).
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== "/" || e.metaKey || e.ctrlKey || e.altKey) return;
const t = e.target as HTMLElement | null;
const tag = t?.tagName?.toLowerCase();
if (tag === "input" || tag === "textarea" || t?.isContentEditable) return;
e.preventDefault();
inputRef.current?.focus();
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, []);
const suggestions = useMemo(() => {
const trimmed = value.trimStart();
if (!trimmed.startsWith("/")) return [] as CommandVerb[];
const verb = trimmed.slice(1).split(/\s+/)[0]?.toLowerCase() ?? "";
if (!verb) return VERBS;
return VERBS.filter((v) => v.startsWith(verb));
}, [value]);
const submit = useCallback(
(raw: string) => {
const trimmed = raw.trim();
if (!trimmed.startsWith("/")) {
setResult({ ok: false, message: "commands start with /" });
return;
}
const body = trimmed.slice(1);
const [verbRaw, ...rest] = body.split(/\s+/);
const verb = (verbRaw?.toLowerCase() ?? "") as CommandVerb;
if (!VERBS.includes(verb)) {
setResult({
ok: false,
message: `unknown verb "${verbRaw}" — try ${VERBS.join(", ")}`,
});
return;
}
const args = rest.join(" ");
try {
const ret = onCommand?.(verb, args);
const message =
(ret && typeof ret === "object" && "message" in ret && ret.message) ||
defaultMessage(verb, args);
setResult({ ok: true, message });
} catch (err) {
setResult({
ok: false,
message: err instanceof Error ? err.message : "command failed",
});
}
setHistory((h) => [trimmed, ...h].slice(0, 32));
setHistoryIdx(-1);
},
[onCommand],
);
const onSubmit = (e: FormEvent) => {
e.preventDefault();
if (value.trim()) {
submit(value);
setValue("");
}
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "ArrowUp") {
e.preventDefault();
setHistoryIdx((idx) => {
const next = idx + 1;
if (next >= history.length) return idx;
setValue(history[next] ?? "");
return next;
});
} else if (e.key === "ArrowDown") {
e.preventDefault();
setHistoryIdx((idx) => {
const next = idx - 1;
if (next < -1) return idx;
setValue(next === -1 ? "" : (history[next] ?? ""));
return next;
});
}
};
return (
<form
onSubmit={onSubmit}
className="sticky bottom-0 z-10 flex h-11 items-center gap-2 border-t border-[var(--color-hairline)] bg-[var(--color-canvas)] px-3 font-mono text-[12px]"
role="search"
>
<span className="shrink-0 text-[var(--color-signal)]">{">"}</span>
<input
ref={inputRef}
type="text"
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={onKeyDown}
placeholder={placeholder}
spellCheck={false}
autoComplete="off"
aria-label="Command line"
className="min-w-0 flex-1 bg-transparent text-[var(--color-ink)] outline-none placeholder:text-[var(--color-ink-soft)]"
/>
{result ? (
<span
className={cn(
"shrink-0 truncate text-[10px] uppercase tracking-wide",
result.ok
? "text-[var(--color-signal)]"
: "text-[var(--color-vermilion)]",
)}
>
{result.message}
</span>
) : suggestions.length > 0 ? (
<span className="shrink-0 truncate text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
{suggestions.map((s) => `/${s}`).join(" ")}
</span>
) : null}
</form>
);
}
function defaultMessage(verb: CommandVerb, args: string): string {
switch (verb) {
case "mute":
return args ? `mute queued — ${args}` : "mute needs a target";
case "jump":
return args ? `jump queued — ${args}` : "jump needs a channel";
case "find":
return args ? `find queued — ${args}` : "find needs text";
case "clear":
return "feed cleared";
}
}
@@ -0,0 +1,273 @@
"use client";
/**
* EventFeed — horizontal scroll-snap timeline that ingests live events.
*
* The feed is the central column of the dashboard. Time runs left → right
* (older → newer). New events append at the right edge; the feed scrolls
* right when the user is at the live edge and pauses when the user drags
* back to inspect history.
*
* Ring buffer keeps the DOM bounded (200 items). A `NowMarker` is inserted
* every 10 events or every 30 seconds to break the row rhythm with a pulse
* summary — see `useFeedPulse`.
*/
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { EventRow, type FeedEvent } from "@/components/feed/event-row";
import { ClusterMarker, PulseMarker } from "@/components/feed/now-marker";
import { cn } from "@/lib/utils";
const RING_BUFFER_MAX = 200;
const PULSE_EVERY_N_EVENTS = 10;
const PULSE_EVERY_MS = 30_000;
export type FeedItem =
| { kind: "event"; event: FeedEvent }
| {
kind: "pulse";
key: string;
ts: number;
label: string;
summary: string;
tone?: "signal" | "amber" | "vermilion";
}
| {
kind: "cluster";
key: string;
ts: number;
label: string;
bands: {
tone: "neutral" | "signal" | "amber" | "vermilion";
ratio: number;
}[];
tone?: "signal" | "amber" | "vermilion";
};
interface EventFeedProps {
initialEvents: FeedEvent[];
subscribe: (handler: (e: FeedEvent) => void) => () => void;
className?: string;
emptyState?: ReactNode;
}
export function EventFeed({
initialEvents,
subscribe,
className,
emptyState,
}: EventFeedProps) {
const [items, setItems] = useState<FeedItem[]>(() =>
injectMarkers(initialEvents.slice(-RING_BUFFER_MAX)),
);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [following, setFollowing] = useState(true);
const scrollerRef = useRef<HTMLDivElement | null>(null);
const lastPulseAt = useRef<number>(Date.now());
// Live WS ingest
useEffect(() => {
const unsub = subscribe((e) => {
setItems((prev) => appendWithMarker(prev, e));
});
return unsub;
}, [subscribe]);
// Periodic pulse even if traffic is slow — keeps the feed rhythm alive.
useEffect(() => {
const id = window.setInterval(() => {
setItems((prev) => {
if (Date.now() - lastPulseAt.current < PULSE_EVERY_MS) return prev;
return appendPulse(prev, "system", "live · standing by");
});
}, PULSE_EVERY_MS);
return () => window.clearInterval(id);
}, []);
// Auto-scroll on append when following.
useEffect(() => {
if (!following) return;
const el = scrollerRef.current;
if (!el) return;
el.scrollTo({ left: el.scrollWidth, behavior: "smooth" });
}, [following]);
const handleScroll = useCallback(() => {
const el = scrollerRef.current;
if (!el) return;
const distFromRight = el.scrollWidth - el.scrollLeft - el.clientWidth;
setFollowing(distFromRight < 24);
}, []);
const handleSelect = useCallback((id: string) => {
setSelectedId((cur) => (cur === id ? null : id));
}, []);
const visibleItems = useMemo(() => {
if (items.length <= RING_BUFFER_MAX) return items;
return items.slice(items.length - RING_BUFFER_MAX);
}, [items]);
return (
<div
className={cn(
"relative h-full w-full overflow-hidden",
"border-t border-[var(--color-hairline)]",
className,
)}
data-following={following ? "1" : "0"}
>
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] bg-[var(--color-surface)] px-3 py-1.5 font-mono text-[10px] uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
<span>event horizon</span>
<span>
{visibleItems.filter((i) => i.kind === "event").length} events ·{" "}
{following ? "live" : "paused"}
</span>
</div>
<div
ref={scrollerRef}
onScroll={handleScroll}
className={cn(
"h-[calc(100%-30px)] overflow-y-auto overflow-x-hidden",
"snap-y snap-mandatory",
"scroll-pt-2",
)}
role="feed"
aria-live="polite"
>
{visibleItems.length === 0 && emptyState ? (
<div className="flex h-full items-center justify-center p-8 text-center font-mono text-[12px] text-[var(--color-ink-soft)]">
{emptyState}
</div>
) : (
visibleItems.map((item) => {
if (item.kind === "event") {
return (
<div key={item.event.id} className="snap-start">
<EventRow
event={item.event}
selected={selectedId === item.event.id}
onSelect={handleSelect}
/>
</div>
);
}
if (item.kind === "cluster") {
return (
<div key={item.key} className="snap-start">
<ClusterMarker
label={item.label}
timestamp={item.ts}
bands={item.bands}
tone={item.tone}
/>
</div>
);
}
return (
<div key={item.key} className="snap-start">
<PulseMarker
label={item.label}
timestamp={item.ts}
trailing={item.summary}
tone={item.tone}
/>
</div>
);
})
)}
</div>
</div>
);
}
// ── Ring + pulse helpers ────────────────────────────────────────
function injectMarkers(events: FeedEvent[]): FeedItem[] {
if (events.length === 0) return [];
const out: FeedItem[] = [];
let count = 0;
for (const e of events) {
out.push({ kind: "event", event: e });
count++;
if (count % PULSE_EVERY_N_EVENTS === 0) {
out.push({
kind: "cluster",
key: `cluster-${e.id}`,
ts: e.ts,
label: "pulse",
bands: deriveBands(
events.slice(Math.max(0, count - PULSE_EVERY_N_EVENTS), count),
),
tone: "signal",
});
}
}
return out;
}
function deriveBands(
window: FeedEvent[],
): { tone: "neutral" | "signal" | "amber" | "vermilion"; ratio: number }[] {
const counts: Record<"neutral" | "signal" | "amber" | "vermilion", number> = {
neutral: 0,
signal: 0,
amber: 0,
vermilion: 0,
};
for (const e of window) counts[e.severity]++;
const total = window.length || 1;
return (Object.keys(counts) as Array<keyof typeof counts>).map((k) => ({
tone: k,
ratio: counts[k] / total,
}));
}
function appendWithMarker(prev: FeedItem[], e: FeedEvent): FeedItem[] {
const next = [...prev, { kind: "event" as const, event: e }];
const eventsSinceLastPulse = next.filter((i) => i.kind === "event").length;
if (eventsSinceLastPulse % PULSE_EVERY_N_EVENTS === 0) {
const recentEvents = next
.filter((i) => i.kind === "event")
.slice(-PULSE_EVERY_N_EVENTS)
.map((i) => (i as { kind: "event"; event: FeedEvent }).event);
next.push({
kind: "cluster",
key: `cluster-${e.id}`,
ts: e.ts,
label: "pulse",
bands: deriveBands(recentEvents),
tone: "signal",
});
}
if (next.length > RING_BUFFER_MAX * 2) {
return next.slice(next.length - RING_BUFFER_MAX);
}
return next;
}
function appendPulse(
prev: FeedItem[],
label: string,
summary: string,
): FeedItem[] {
return [
...prev,
{
kind: "pulse",
key: `pulse-${Date.now()}`,
ts: Date.now(),
label,
summary,
tone: "signal",
},
];
}
@@ -0,0 +1,133 @@
"use client";
/**
* EventRow — single row in the horizontal event-feed timeline.
*
* No card chrome. The row is a single typographic line: mono timestamp,
* severity dot, actor mention, action verb, channel jump, excerpt.
*
* Hover reveals full excerpt and selection state; click toggles selection
* so the right rail / command line can target the event.
*/
import { type ReactNode, useCallback } from "react";
import { cn } from "@/lib/utils";
export type EventSeverity = "neutral" | "signal" | "amber" | "vermilion";
export interface FeedEvent {
/** Stable id from the upstream record. Used as React key. */
id: string;
/** Unix epoch ms. */
ts: number;
/** Severity tone — drives dot color and zebra fill. */
severity: EventSeverity;
/** Display label for the actor ("alice", "@everyone", "Carl-bot"). */
actor: string;
/** Verb describing the action ("sent", "flagged", "joined", "muted"). */
action: string;
/** Channel reference (monogram display only — no chrome). */
channel?: string | null;
/** Message excerpt or action payload text. Truncated when long. */
excerpt: string;
/** Optional metadata tag (e.g. "ai:flag", "voice:join"). */
tag?: string | null;
}
interface EventRowProps {
event: FeedEvent;
selected?: boolean;
onSelect?: (id: string) => void;
}
const SEVERITY_DOT: Record<EventSeverity, string> = {
neutral: "oklch(0.46 0.02 70)",
signal: "var(--color-signal)",
amber: "var(--color-amber)",
vermilion: "var(--color-vermilion)",
};
const SEVERITY_FILL: Record<EventSeverity, string> = {
neutral: "transparent",
signal: "oklch(0.78 0.17 125 / 0.06)",
amber: "oklch(0.80 0.15 70 / 0.07)",
vermilion: "oklch(0.62 0.21 25 / 0.08)",
};
function formatTimestamp(ts: number): string {
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
export function EventRow({ event, selected, onSelect }: EventRowProps) {
const handleClick = useCallback(() => {
onSelect?.(event.id);
}, [event.id, onSelect]);
const dot: ReactNode = (
<span
aria-hidden
className="inline-block size-1.5 shrink-0 rounded-full"
style={{ background: SEVERITY_DOT[event.severity] }}
/>
);
return (
<button
type="button"
onClick={handleClick}
className={cn(
"group relative flex w-full items-baseline gap-3 px-3 py-1.5 text-left font-mono text-[12px] leading-5 transition-colors",
"hover:bg-[oklch(0.92_0.014_80_/_0.6)] focus-visible:outline focus-visible:outline-2 focus-visible:outline-[var(--color-signal)] focus-visible:outline-offset-[-2px]",
selected && "bg-[oklch(0.92_0.014_80_/_0.8)]",
)}
style={{
background: selected ? undefined : SEVERITY_FILL[event.severity],
}}
data-event-id={event.id}
data-severity={event.severity}
>
<span
aria-hidden
className={cn(
"absolute inset-y-0 left-0 w-[2px] origin-center transition-transform",
selected ? "scale-y-100" : "scale-y-0 group-hover:scale-y-100",
)}
style={{ background: SEVERITY_DOT[event.severity] }}
/>
<span className="w-[68px] shrink-0 text-[var(--color-ink-soft)] tabular-nums">
{formatTimestamp(event.ts)}
</span>
{dot}
<span className="w-[120px] shrink-0 truncate text-[var(--color-ink)]">
{event.actor}
</span>
<span className="w-[80px] shrink-0 text-[var(--color-ink-soft)]">
{event.action}
</span>
{event.channel ? (
<span className="w-[140px] shrink-0 truncate text-[var(--color-ink-soft)]">
{event.channel}
</span>
) : (
<span className="w-[140px] shrink-0" aria-hidden />
)}
<span className="min-w-0 flex-1 truncate text-[var(--color-ink)]">
{event.excerpt}
</span>
{event.tag ? (
<span className="shrink-0 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] px-1.5 py-px text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
{event.tag}
</span>
) : null}
</button>
);
}
@@ -0,0 +1,138 @@
"use client";
/**
* NowMarker — inline callout that breaks the feed timeline rhythm.
*
* Two variants: `pulse` (one-line summary) and `cluster` (horizontal stack bar
* visualising severity distribution across a recent window). Both use a
* border-tip on the left in signal tone; no card chrome, no shadow.
*/
import { cn } from "@/lib/utils";
type Tone = "signal" | "amber" | "vermilion" | "neutral";
interface PulseMarkerProps {
tone?: Tone;
label: string;
timestamp: number;
/** Optional small caps label on the right. */
trailing?: string;
}
interface ClusterMarkerProps {
tone?: Tone;
label: string;
timestamp: number;
/** Fractions of each severity band; must sum to 1. */
bands: { tone: Tone; ratio: number }[];
}
const TONE_TIP: Record<Tone, string> = {
signal: "var(--color-signal)",
amber: "var(--color-amber)",
vermilion: "var(--color-vermilion)",
neutral: "oklch(0.46 0.02 70)",
};
const TONE_FILL: Record<Tone, string> = {
signal: "oklch(0.78 0.17 125 / 0.12)",
amber: "oklch(0.80 0.15 70 / 0.14)",
vermilion: "oklch(0.62 0.21 25 / 0.12)",
neutral: "oklch(0.46 0.02 70 / 0.08)",
};
function formatTimestamp(ts: number): string {
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}
function MarkerShell({
tone,
label,
timestamp,
trailing,
children,
}: {
tone: Tone;
label: string;
timestamp: number;
trailing?: string;
children?: React.ReactNode;
}) {
return (
<div
className="relative my-2 flex items-center gap-3 px-3 py-2 font-mono text-[11px]"
style={{ background: TONE_FILL[tone] }}
data-marker={tone}
>
<span
aria-hidden
className="absolute inset-y-1 left-0 w-[3px]"
style={{ background: TONE_TIP[tone] }}
/>
<span className="w-[68px] shrink-0 text-[var(--color-ink-soft)] tabular-nums">
{formatTimestamp(timestamp)}
</span>
<span
className="shrink-0 text-[10px] font-medium uppercase tracking-[0.18em]"
style={{ color: TONE_TIP[tone] }}
>
{label}
</span>
<span className="min-w-0 flex-1 truncate text-[var(--color-ink)]">
{children}
</span>
{trailing ? (
<span className="shrink-0 text-[10px] uppercase tracking-wide text-[var(--color-ink-soft)]">
{trailing}
</span>
) : null}
</div>
);
}
export function PulseMarker({
tone = "signal",
label,
timestamp,
trailing,
}: PulseMarkerProps) {
return (
<MarkerShell
tone={tone}
label={label}
timestamp={timestamp}
trailing={trailing}
>
{/* children rendered by parent via composition — see NowMarker union below */}
</MarkerShell>
);
}
export function ClusterMarker({
tone = "signal",
label,
timestamp,
bands,
}: ClusterMarkerProps) {
return (
<MarkerShell tone={tone} label={label} timestamp={timestamp}>
<div className="flex h-3 w-full max-w-[280px] overflow-hidden rounded-[var(--radius-r-control)]">
{bands.map((b) => (
<span
key={b.tone}
className={cn("h-full")}
style={{
width: `${Math.max(0, Math.min(1, b.ratio)) * 100}%`,
background: TONE_TIP[b.tone],
opacity: b.tone === "neutral" ? 0.4 : 1,
}}
aria-hidden
/>
))}
</div>
</MarkerShell>
);
}
@@ -0,0 +1,92 @@
"use client";
/**
* DashLeftRail — 80px vertical monogram nav.
*
* Each item is a glyph + label. Active state uses an accent bar on the left
* and full ink colour. No backgrounds, no boxes.
*/
import {
Activity,
BarChart3,
Flag,
MessagesSquare,
Mic,
ShieldCheck,
Users,
} from "lucide-react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { cn } from "@/lib/utils";
interface NavItem {
href: string;
glyph: React.ReactNode;
label: string;
}
const ITEMS: NavItem[] = [
{
href: "/dashboard",
glyph: <BarChart3 className="size-4" />,
label: "Console",
},
{
href: "/messages",
glyph: <MessagesSquare className="size-4" />,
label: "Messages",
},
{
href: "/moderation",
glyph: <ShieldCheck className="size-4" />,
label: "Moderation",
},
{ href: "/voice", glyph: <Mic className="size-4" />, label: "Voice" },
{ href: "/media", glyph: <Activity className="size-4" />, label: "Media" },
{
href: "/recordings",
glyph: <Flag className="size-4" />,
label: "Recordings",
},
{ href: "/analysis", glyph: <Users className="size-4" />, label: "Analysis" },
];
export function DashLeftRail() {
const pathname = usePathname();
return (
<nav
aria-label="Console navigation"
className="flex h-full w-20 shrink-0 flex-col items-center gap-1 border-r border-[var(--color-hairline)] bg-[var(--color-surface)] py-3"
>
{ITEMS.map((it) => {
const active =
pathname === it.href || pathname?.startsWith(`${it.href}/`);
return (
<Link
key={it.href}
href={it.href}
className={cn(
"group relative flex w-full flex-col items-center gap-1 py-2 text-[10px] uppercase tracking-wide transition-colors",
active
? "text-[var(--color-ink)]"
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
)}
data-active={active ? "1" : "0"}
>
<span
aria-hidden
className={cn(
"absolute inset-y-2 left-0 w-[2px] origin-center transition-transform",
active ? "scale-y-100" : "scale-y-0 group-hover:scale-y-100",
)}
style={{ background: "var(--color-signal)" }}
/>
{it.glyph}
<span className="font-mono">{it.label}</span>
</Link>
);
})}
</nav>
);
}
@@ -0,0 +1,183 @@
"use client";
/**
* DashRightRail — 320px collapsible drawer.
*
* Holds the live AI verdict stream, active voice speakers, and the latest
* moderation actions. Reads from existing hooks (`useVoice`, etc.) — no
* new fetches; just re-presentation.
*/
import { ChevronRight } from "lucide-react";
import { useEffect, useState } from "react";
import { useSpeakers } from "@/hooks/use-voice";
import type { ActiveSpeaker } from "@/lib/types";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
interface DashRightRailProps {
pendingVerdicts?: { id: string; ts: number; text: string }[];
recentActions?: { id: string; ts: number; verb: string; target: string }[];
}
export function DashRightRail({
pendingVerdicts = [],
recentActions = [],
}: DashRightRailProps) {
const [collapsed, setCollapsed] = useState(false);
const { subscribe } = useSpeakers();
const ws = useWebSocket();
const [speakers, _setSpeakers] = useState<ActiveSpeaker[]>([]);
useEffect(() => subscribe(ws), [ws, subscribe]);
return (
<aside
className={cn(
"relative shrink-0 border-l border-[var(--color-hairline)] bg-[var(--color-surface)] font-mono text-[11px] transition-[width]",
collapsed ? "w-9" : "w-[320px]",
)}
aria-label="Live activity rail"
>
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
className={cn(
"absolute -left-3 top-3 z-10 flex size-6 items-center justify-center rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)] text-[var(--color-ink-soft)] transition-colors hover:text-[var(--color-ink)]",
)}
aria-label={
collapsed
? "Expand live activity rail"
: "Collapse live activity rail"
}
>
<ChevronRight
className={cn(
"size-3 transition-transform",
collapsed ? "" : "rotate-180",
)}
/>
</button>
{collapsed ? (
<div className="flex h-full flex-col items-center gap-4 py-4">
<Section title="ai" vertical />
<Section title="voice" vertical />
<Section title="mod" vertical />
</div>
) : (
<div className="flex h-full flex-col overflow-y-auto">
<Section title="ai verdicts">
{pendingVerdicts.length === 0 ? (
<Empty msg="no pending verdicts" />
) : (
<ul className="flex flex-col gap-1.5">
{pendingVerdicts.slice(0, 8).map((v) => (
<li key={v.id} className="flex items-baseline gap-2">
<span className="shrink-0 text-[var(--color-ink-soft)] tabular-nums">
{formatTs(v.ts)}
</span>
<span className="min-w-0 truncate text-[var(--color-ink)]">
{v.text}
</span>
</li>
))}
</ul>
)}
</Section>
<Section title="voice">
{speakers.length === 0 ? (
<Empty msg="no one speaking" />
) : (
<ul className="flex flex-col gap-1.5">
{speakers.slice(0, 8).map((sp) => (
<li key={sp.userId} className="flex items-center gap-2">
<span
className={cn(
"inline-block size-1.5 rounded-full",
sp.speaking
? "bg-[var(--color-signal)]"
: "bg-[var(--color-ink-soft)]",
)}
aria-hidden
/>
<span className="truncate text-[var(--color-ink)]">
{sp.username ?? sp.userId}
</span>
</li>
))}
</ul>
)}
</Section>
<Section title="mod queue">
{recentActions.length === 0 ? (
<Empty msg="queue empty" />
) : (
<ul className="flex flex-col gap-1.5">
{recentActions.slice(0, 8).map((a) => (
<li key={a.id} className="flex items-baseline gap-2">
<span className="shrink-0 text-[var(--color-ink-soft)] tabular-nums">
{formatTs(a.ts)}
</span>
<span className="text-[var(--color-ink-soft)]">
{a.verb}
</span>
<span className="min-w-0 truncate text-[var(--color-ink)]">
{a.target}
</span>
</li>
))}
</ul>
)}
</Section>
<Section title="socket">
<div className="flex flex-col gap-0.5 text-[10px]">
<span className="text-[var(--color-ink-soft)]">status</span>
<span className="text-[var(--color-ink)]">{ws.status}</span>
</div>
</Section>
</div>
)}
</aside>
);
}
function Section({
title,
children,
vertical,
}: {
title: string;
children?: React.ReactNode;
vertical?: boolean;
}) {
return (
<section
className={cn(
"border-b border-[var(--color-hairline)] px-3 py-2.5",
vertical && "flex flex-col items-center gap-2 border-b-0 py-4",
)}
>
<h3 className="mb-1.5 text-[10px] uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
{title}
</h3>
{children}
</section>
);
}
function Empty({ msg }: { msg: string }) {
return (
<span className="text-[10px] italic text-[var(--color-ink-soft)]">
{msg}
</span>
);
}
function formatTs(ts: number): string {
const d = new Date(ts);
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
@@ -0,0 +1,115 @@
"use client";
/**
* DashTopBar — 48px utility strip.
*
* No navigation chrome — just brand monogram, guild indicator, WS connection
* state, clock, and focus mode. Designed to read as a single line of
* instrument readout, not a navbar.
*/
import { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
type FocusMode = "quiet" | "standard" | "triage";
const FOCUS_MODES: FocusMode[] = ["quiet", "standard", "triage"];
interface DashTopBarProps {
guildName: string;
botName?: string;
}
function formatClock(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}`;
}
export function DashTopBar({ guildName, botName = "GMW" }: DashTopBarProps) {
const ws = useWebSocket();
const [now, setNow] = useState<Date | null>(null);
const [focus, setFocus] = useState<FocusMode>("standard");
const [tz, setTz] = useState<"utc" | "local">("local");
useEffect(() => {
setNow(new Date());
const id = window.setInterval(() => setNow(new Date()), 1000);
return () => window.clearInterval(id);
}, []);
const connected = ws.status === "connected";
return (
<header
className={cn(
"flex h-12 items-center justify-between gap-4 border-b border-[var(--color-hairline)] bg-[var(--color-surface)] px-4 font-mono text-[11px]",
)}
>
<div className="flex items-center gap-3">
<span className="display text-base font-medium text-[var(--color-ink)]">
{botName}
</span>
<span className="text-[var(--color-ink-soft)]">·</span>
<span className="text-[var(--color-ink-soft)]">{guildName}</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-1.5">
<span
aria-hidden
className={cn(
"inline-block size-1.5 rounded-full",
connected
? "bg-[var(--color-signal)]"
: "bg-[var(--color-vermilion)]",
)}
style={{
boxShadow: connected
? "0 0 0 0 oklch(from var(--color-signal) l c h / 0.45)"
: "none",
}}
/>
<span className="uppercase tracking-[0.18em] text-[var(--color-ink-soft)]">
{ws.status}
</span>
</div>
<button
type="button"
onClick={() => setTz((t) => (t === "utc" ? "local" : "utc"))}
className="rounded-[var(--radius-r-control)] px-2 py-0.5 text-[var(--color-ink-soft)] transition-colors hover:bg-[var(--color-surface-2)] hover:text-[var(--color-ink)]"
aria-label="Toggle UTC / local timezone"
>
{now
? tz === "utc"
? `${formatClock(now)} UTC`
: formatLocal(now)
: "--:--:--"}
</button>
<div className="flex gap-0.5 rounded-[var(--radius-r-control)] bg-[var(--color-surface-2)] p-0.5">
{FOCUS_MODES.map((m) => (
<button
key={m}
type="button"
onClick={() => setFocus(m)}
className={cn(
"rounded-[var(--radius-r-control)] px-2 py-0.5 text-[10px] uppercase tracking-wide transition-colors",
focus === m
? "bg-[var(--color-canvas)] text-[var(--color-ink)]"
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]",
)}
>
{m}
</button>
))}
</div>
</div>
</header>
);
}
function formatLocal(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`;
}