mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-04 12:33:08 +00:00
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:
parent
9f02eea1d2
commit
6776b2f9b5
7 changed files with 145 additions and 66 deletions
|
|
@ -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 => ({
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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 => (
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue