feat(desktop): live gateway popout + statusbar/command-center polish

- Gateway status popout: flatten the header to stacked connection + inference
  statuses with system-panel and restart actions (reusing the shared
  runGatewayRestart helper). The recent-activity tail is now live while the
  popout is open via the shared LogView (WS connection churn filtered), and the
  icon / "View all logs" link dismiss the popover.
- Statusbar "menu" items accept a menuContent(close) render fn over a now
  controlled DropdownMenu, so popover content can close itself.
- Drop the always-on gateway-log poll from useStatusSnapshot (logs are fetched
  by the popout only while open).
- SearchField → text-xs to match Input/Select (controlVariants).
- Command center: remove the usage/system section dividers, swap the sessions
  nav icon (Pin → MessageCircle), small padding tweaks.
This commit is contained in:
Brooklyn Nicholson 2026-06-28 21:26:15 -05:00
parent 9f02eea1d2
commit 6776b2f9b5
7 changed files with 145 additions and 66 deletions

View file

@ -9,7 +9,7 @@ import { getActionStatus, getLogs, getStatus, getUsageAnalytics, restartGateway,
import type { ActionStatusResponse, AnalyticsResponse, StatusResponse } from '@/hermes'
import { useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { Activity, AlertCircle, BarChart3, Bookmark, BookmarkFilled, Download, Pin, Trash2 } from '@/lib/icons'
import { Activity, AlertCircle, BarChart3, Bookmark, BookmarkFilled, Download, MessageCircle, Trash2 } from '@/lib/icons'
import { exportSession } from '@/lib/session-export'
import { cn } from '@/lib/utils'
import { upsertDesktopActionTask } from '@/store/activity'
@ -263,7 +263,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
{SECTIONS.map(value => (
<OverlayNavItem
active={section === value}
icon={value === 'sessions' ? Pin : value === 'system' ? Activity : BarChart3}
icon={value === 'sessions' ? MessageCircle : value === 'system' ? Activity : BarChart3}
key={value}
label={cc.sections[value]}
onClick={() => setSection(value)}
@ -361,7 +361,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
/>
) : (
<div className="grid min-h-0 flex-1 grid-rows-[auto_minmax(0,1fr)] gap-4">
<div className="border-b border-(--ui-stroke-tertiary) pb-4">
<div>
{status ? (
<div className="grid gap-2">
<div className="flex items-start justify-between gap-3">
@ -406,7 +406,7 @@ export function CommandCenterView({ initialSection, onClose, onDeleteSession, on
)}
</div>
<div className="flex min-h-0 flex-col">
<div className="flex min-h-0 flex-col pt-2">
<div className="mb-2 flex items-center justify-between">
<span className="text-[0.625rem] font-medium uppercase tracking-[0.08em] text-(--ui-text-tertiary)">
{cc.recentLogs}
@ -503,7 +503,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
</span>
)}
<div className="grid grid-cols-2 gap-x-4 gap-y-4 border-b border-(--ui-stroke-tertiary) pb-5 sm:grid-cols-3">
<div className="grid grid-cols-2 gap-x-4 gap-y-4 py-2 sm:grid-cols-3">
<UsageStat label={cc.statSessions} value={formatInteger(totals.total_sessions)} />
<UsageStat label={cc.statApiCalls} value={formatInteger(totals.total_api_calls)} />
<UsageStat
@ -563,7 +563,7 @@ function UsagePanel({ error, loading, onRefresh, period, usage }: UsagePanelProp
)}
</section>
<div className="grid min-h-0 gap-x-8 gap-y-5 border-t border-(--ui-stroke-tertiary) pt-5 sm:grid-cols-2">
<div className="grid min-h-0 gap-x-8 gap-y-5 pt-1 sm:grid-cols-2">
<UsageList
emptyLabel={cc.noModelUsage}
rows={byModel.slice(0, 6).map(entry => ({

View file

@ -581,7 +581,7 @@ export function DesktopController() {
}
}, [])
const { gatewayLogLines, inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway)
const { inferenceStatus, statusSnapshot } = useStatusSnapshot(gatewayState, requestGateway)
const updateActiveSessionRuntimeInfo = useCallback(
(info: { branch?: string; cwd?: string }) => {
@ -1061,7 +1061,6 @@ export function DesktopController() {
commandCenterOpen,
extraLeftItems: statusbarItemGroups.flat.left,
extraRightItems: statusbarItemGroups.flat.right,
gatewayLogLines,
gatewayState,
inferenceStatus,
openAgents,

View file

@ -1,20 +1,68 @@
import { useEffect, useRef, useState } from 'react'
import { StatusDot, type StatusTone } from '@/components/status-dot'
import { Button } from '@/components/ui/button'
import { LogView } from '@/components/ui/log-view'
import { Tip } from '@/components/ui/tooltip'
import { getLogs } from '@/hermes'
import { useI18n } from '@/i18n'
import { Activity, AlertCircle, LayoutDashboard } from '@/lib/icons'
import { LayoutDashboard, RefreshCw } from '@/lib/icons'
import type { RuntimeReadinessResult } from '@/lib/runtime-readiness'
import { cn } from '@/lib/utils'
import { runGatewayRestart } from '@/store/system-actions'
import type { StatusResponse } from '@/types/hermes'
interface GatewayMenuPanelProps {
gatewayState: string
inferenceStatus: RuntimeReadinessResult | null
logLines: readonly string[]
onClose: () => void
onOpenSystem: () => void
statusSnapshot: StatusResponse | null
}
const LOG_TAIL = 120
const LOG_VISIBLE = 40
const LOG_POLL_MS = 3_000
// Per-connection WebSocket churn (accept/close/heartbeat) drowns out anything
// useful — strip it so the tail reads as real gateway activity at a glance.
const LOG_NOISE_RE = /\bws (?:accepted|closed|response sent|ping|pong)\b/i
// Live tail while the popover is mounted (i.e. open): poll on a tight cadence
// and stop on unmount, instead of a global always-on status poll.
function useGatewayLogTail(): string[] {
const [lines, setLines] = useState<string[]>([])
useEffect(() => {
let cancelled = false
const load = () =>
getLogs({ file: 'gui', lines: LOG_TAIL })
.then(res => {
if (cancelled) {
return
}
setLines(
res.lines
.map(line => line.trim())
.filter(line => line && !LOG_NOISE_RE.test(line))
.slice(-LOG_VISIBLE)
)
})
.catch(() => {})
void load()
const timer = window.setInterval(load, LOG_POLL_MS)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [])
return lines
}
const PLATFORM_TONE: Record<string, StatusTone> = {
connected: 'good',
connecting: 'warn',
@ -35,12 +83,27 @@ const trimLogLine = (raw: string) => raw.trim().replace(TIMESTAMP_RE, '').replac
export function GatewayMenuPanel({
gatewayState,
inferenceStatus,
logLines,
onClose,
onOpenSystem,
statusSnapshot
}: GatewayMenuPanelProps) {
const { t } = useI18n()
const copy = t.shell.gatewayMenu
// Both jumps open the system panel, which owns the full view — so dismiss the
// little status popover on the way out.
const openSystem = () => {
onClose()
onOpenSystem()
}
// Shared restart helper: never rejects and surfaces progress in the statusbar
// gateway indicator, so just fire and close.
const restart = () => {
onClose()
void runGatewayRestart()
}
const gatewayOpen = gatewayState === 'open'
const gatewayConnecting = gatewayState === 'connecting'
const inferenceReady = gatewayOpen && inferenceStatus?.ready === true
@ -60,30 +123,50 @@ export function GatewayMenuPanel({
: copy.disconnected
const platforms = Object.entries(statusSnapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r))
const recentLogs = logLines.slice(-5)
const recentLogs = useGatewayLogTail()
// Keep the tail pinned to the latest line as it streams.
const logScrollRef = useRef<HTMLDivElement>(null)
useEffect(() => {
const el = logScrollRef.current
if (el) {
el.scrollTop = el.scrollHeight
}
}, [recentLogs])
return (
<div className="text-sm">
<div className="flex items-center justify-between gap-2 px-3 py-2.5">
<div className="flex min-w-0 items-center gap-2">
{inferenceReady ? (
<Activity className="size-3.5 text-primary" />
) : (
<AlertCircle className={cn('size-3.5', gatewayOpen ? 'text-amber-600' : 'text-destructive')} />
)}
<span className="font-medium">{copy.gateway}</span>
<span className="flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="flex items-center justify-between gap-3 px-3 py-2">
<div className="flex min-w-0 flex-col gap-1 text-[0.7rem] leading-none">
<span className="flex items-center gap-1.5 font-medium">
<StatusDot tone={gatewayOpen ? 'good' : gatewayConnecting ? 'warn' : 'bad'} />
{connectionLabel}
</span>
<span className="flex items-center gap-1.5 text-muted-foreground">
<StatusDot tone={inferenceReady ? 'good' : gatewayOpen ? 'warn' : 'bad'} />
{inferenceLabel}
</span>
</div>
<div className="flex items-center">
<div className="flex shrink-0 items-center gap-0.5">
<Tip label={t.commandCenter.restartGateway}>
<Button
aria-label={t.commandCenter.restartGateway}
className="text-muted-foreground hover:text-foreground"
onClick={restart}
size="icon-xs"
variant="ghost"
>
<RefreshCw />
</Button>
</Tip>
<Tip label={copy.openSystem}>
<Button
aria-label={copy.openSystem}
className="text-muted-foreground hover:text-foreground"
onClick={onOpenSystem}
size="icon-sm"
onClick={openSystem}
size="icon-xs"
variant="ghost"
>
<LayoutDashboard />
@ -92,32 +175,29 @@ export function GatewayMenuPanel({
</div>
</div>
<div className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground">
<div>{copy.connection(connectionLabel)}</div>
{inferenceStatus?.reason && <div className="mt-1 line-clamp-3">{inferenceStatus.reason}</div>}
</div>
{inferenceStatus?.reason && (
<div className="border-t border-border/50 px-3 py-2 text-xs text-muted-foreground">
<div className="line-clamp-3">{inferenceStatus.reason}</div>
</div>
)}
{recentLogs.length > 0 && (
<div className="border-t border-border/50 px-3 py-2">
<SectionLabel>{copy.recentActivity}</SectionLabel>
<ul className="mt-1.5 space-y-0.5">
{recentLogs.map((line, index) => (
<Tip key={`${index}:${line}`} label={line.trim()}>
<li className="truncate font-mono text-[0.68rem] text-muted-foreground/85">
{trimLogLine(line) || '\u00A0'}
</li>
</Tip>
))}
</ul>
<Button
className="-ml-2 mt-1.5 font-medium text-muted-foreground"
onClick={onOpenSystem}
size="xs"
type="button"
variant="text"
>
{copy.viewAllLogs}
</Button>
<div className="px-3 py-2">
<div className="flex items-center justify-between gap-2">
<SectionLabel>{copy.recentActivity}</SectionLabel>
<Button
className="-mr-2 h-auto py-0 font-medium leading-none text-muted-foreground"
onClick={openSystem}
size="xs"
type="button"
variant="text"
>
{copy.viewAllLogs}
</Button>
</div>
<LogView className="mt-1.5 max-h-40 border-0 px-0" ref={logScrollRef}>
{recentLogs.map(trimLogLine).join('\n')}
</LogView>
</div>
)}

View file

@ -1,17 +1,15 @@
import { useEffect, useState } from 'react'
import { getLogs, getStatus } from '@/hermes'
import { getStatus } from '@/hermes'
import { evaluateRuntimeReadiness, type RuntimeReadinessResult } from '@/lib/runtime-readiness'
import type { StatusResponse } from '@/types/hermes'
const REFRESH_MS = 15_000
const LOG_TAIL = 12
type GatewayRequester = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
export function useStatusSnapshot(gatewayState: string | undefined, requestGateway: GatewayRequester) {
const [statusSnapshot, setStatusSnapshot] = useState<StatusResponse | null>(null)
const [gatewayLogLines, setGatewayLogLines] = useState<string[]>([])
const [inferenceStatus, setInferenceStatus] = useState<RuntimeReadinessResult | null>(null)
useEffect(() => {
@ -19,9 +17,8 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew
const refresh = async () => {
try {
const [next, logs, inference] = await Promise.all([
const [next, inference] = await Promise.all([
getStatus(),
getLogs({ file: 'gui', lines: LOG_TAIL }).catch(() => ({ lines: [] })),
gatewayState === 'open'
? evaluateRuntimeReadiness(requestGateway).catch(error => ({
checksDisagree: false,
@ -37,7 +34,6 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew
}
setStatusSnapshot(next)
setGatewayLogLines(logs.lines.map(line => line.trim()).filter(Boolean))
setInferenceStatus(inference)
} catch {
// Keep last snapshot through transient gateway flaps.
@ -53,5 +49,5 @@ export function useStatusSnapshot(gatewayState: string | undefined, requestGatew
}
}, [gatewayState, requestGateway])
return { gatewayLogLines, inferenceStatus, statusSnapshot }
return { inferenceStatus, statusSnapshot }
}

View file

@ -43,7 +43,6 @@ interface StatusbarItemsOptions {
commandCenterOpen: boolean
extraLeftItems: readonly StatusbarItem[]
extraRightItems: readonly StatusbarItem[]
gatewayLogLines: readonly string[]
gatewayState: string
inferenceStatus: RuntimeReadinessResult | null
openAgents: () => void
@ -60,7 +59,6 @@ export function useStatusbarItems({
commandCenterOpen,
extraLeftItems,
extraRightItems,
gatewayLogLines,
gatewayState,
inferenceStatus,
openAgents,
@ -131,16 +129,16 @@ export function useStatusbarItems({
const showYoloToggle = gatewayState === 'open' && (!!activeSessionId || freshDraftReady)
const gatewayMenuContent = useMemo(
() => (
() => (close: () => void) => (
<GatewayMenuPanel
gatewayState={gatewayState}
inferenceStatus={inferenceStatus}
logLines={gatewayLogLines}
onClose={close}
onOpenSystem={() => openCommandCenterSection('system')}
statusSnapshot={statusSnapshot}
/>
),
[gatewayLogLines, gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot]
[gatewayState, inferenceStatus, openCommandCenterSection, statusSnapshot]
)
// The indicator must speak the same scope as the Spawn-tree panel it opens:

View file

@ -1,4 +1,4 @@
import type { ComponentProps, ReactNode } from 'react'
import { type ComponentProps, type ReactNode, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from '@/components/ui/dropdown-menu'
@ -34,7 +34,8 @@ export interface StatusbarItem {
href?: string
menuAlign?: 'center' | 'end' | 'start'
menuClassName?: string
menuContent?: ReactNode
// A render fn receives a `close()` to dismiss the popover from inside the content.
menuContent?: ((close: () => void) => ReactNode) | ReactNode
menuItems?: readonly StatusbarMenuItem[]
onSelect?: (modifiers: StatusbarSelectModifiers) => void
title?: string
@ -88,6 +89,8 @@ export function StatusbarControls({ className, leftItems = [], items = [], ...pr
}
function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate: ReturnType<typeof useNavigate> }) {
const [menuOpen, setMenuOpen] = useState(false)
const content = (
<>
{item.icon}
@ -99,7 +102,7 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
if (item.variant === 'menu' && (item.menuContent || (item.menuItems && item.menuItems.length > 0))) {
return (
<Tip label={item.title}>
<DropdownMenu>
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
<DropdownMenuTrigger asChild>
<button className={cn(STATUSBAR_ACTION_CLASS, item.className)} disabled={item.disabled} type="button">
{content}
@ -112,7 +115,9 @@ function StatusbarItemView({ item, navigate }: { item: StatusbarItem; navigate:
sideOffset={8}
>
{item.menuContent
? item.menuContent
? typeof item.menuContent === 'function'
? item.menuContent(() => setMenuOpen(false))
: item.menuContent
: (item.menuItems ?? [])
.filter(menuItem => !menuItem.hidden)
.map(menuItem => (

View file

@ -52,7 +52,8 @@ export function SearchField({
className={cn(
// `field-sizing: content` grows the input to fit the placeholder/typed
// text, capped by the container's max-width — no awkward empty space.
'h-7 max-w-full bg-transparent text-sm text-foreground [field-sizing:content] placeholder:text-muted-foreground focus:outline-none',
// text-xs matches the form controls (Input/Select via controlVariants).
'h-7 max-w-full bg-transparent text-xs text-foreground [field-sizing:content] placeholder:text-muted-foreground focus:outline-none',
inputClassName
)}
onChange={event => onChange(event.target.value)}