Merge pull request #80747 from NousResearch/bb/remove-gateway-pill

fix(desktop): stop double-painting the gateway pill
This commit is contained in:
brooklyn! 2026-08-06 21:36:32 -06:00 committed by GitHub
commit bee2bd8bc2
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 5 additions and 378 deletions

View File

@ -4,10 +4,12 @@ Drop a `<name>/plugin.{ts,tsx}` here that default-exports a `HermesPlugin` and
it registers automatically at boot (vite glob in `../contrib/plugins.ts`), with
the same inventory + live enable/disable contract as runtime plugins.
None ship in-tree today — reference/demo plugins (the counter example, the
gateway-pill 1:1 rebuild, the runtime-loader hello world) live in the companion
Keep this tree for real shipped plugins (and the small authoring fixtures that
dogfood the SDK). One-off demos that rebuild a core chrome piece 1:1 do not
belong here — they double the UI and confuse Settings ▸ Plugins. Publish those
in the companion
[`hermes-example-plugins`](https://github.com/NousResearch/hermes-example-plugins)
repo so the shipped app stays uncluttered.
repo instead.
User- and agent-authored plugins load at runtime from
`$HERMES_HOME/desktop-plugins/<name>/plugin.js` (the disk door) — see the

View File

@ -1,375 +0,0 @@
/**
* Gateway pill the core statusbar gateway-health item implemented 1:1 as a
* plugin: same trigger chrome (declarative `variant: 'menu'` StatusbarItem
* the app's own portal/popover plumbing, so nothing clips), same menu panel
* (connection/inference rows, restart, reason, RECENT ACTIVITY tail,
* messaging platforms), same copy (`useI18n`), same readiness logic
* (`evaluateRuntimeReadiness` over `host.request`). The point: a plugin can
* rebuild a REAL core feature through the SDK alone only
* `@hermes/plugin-sdk` + react (lint-fenced).
*
* Pattern notes:
* - a module-level `atom` shares the readiness poll between the live label
* elements and the menu panel (the same primitive `host.state` uses);
* - label/detail/icon of a DATA item are ReactNodes, so they can be tiny
* components that subscribe a static item shape with live innards.
*/
import {
atom,
Button,
cn,
evaluateRuntimeReadiness,
type HermesPlugin,
host,
icons,
LogView,
type RuntimeReadinessResult,
type StatusbarItem,
StatusDot,
type StatusResponse,
type StatusTone,
Tip,
useI18n,
useValue
} from '@hermes/plugin-sdk'
import { type ReactNode, useEffect, useRef, useState } from 'react'
const READINESS_POLL_MS = 15_000
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
// Strip leading "YYYY-MM-DD HH:MM:SS,mmm " and "[runtime_id] " prefixes.
const TIMESTAMP_RE = /^\d{4}-\d{2}-\d{2}[ T]\d{2}:\d{2}:\d{2}[,.\d]*\s+/
const RUNTIME_BRACKET_RE = /^\[[^\]]+]\s+/
const trimLogLine = (raw: string) => raw.trim().replace(TIMESTAMP_RE, '').replace(RUNTIME_BRACKET_RE, '')
const PLATFORM_TONE: Record<string, StatusTone> = {
connected: 'good',
connecting: 'warn',
retrying: 'warn',
pending_restart: 'warn',
startup_failed: 'bad',
fatal: 'bad'
}
const prettyState = (state: string) => state.replace(/_/g, ' ').replace(/^./, c => c.toUpperCase())
const SYSTEM_PANEL_ROUTE = '/command-center?section=system'
// ---------------------------------------------------------------------------
// Readiness poll — one loop at plugin scope, shared by label + panel.
// ---------------------------------------------------------------------------
const $readiness = atom<null | RuntimeReadinessResult>(null)
function startReadinessPoll() {
let timer: null | number = null
const stop = () => {
if (timer !== null) {
window.clearInterval(timer)
timer = null
}
$readiness.set(null)
}
const refresh = () =>
evaluateRuntimeReadiness(host.request)
.then(next => $readiness.set(next))
.catch(() => undefined)
const sync = (gateway: string) => {
if (gateway !== 'open') {
stop()
return
}
if (timer === null) {
void refresh()
timer = window.setInterval(() => void refresh(), READINESS_POLL_MS)
}
}
sync(host.state.gateway.get())
host.state.gateway.listen(sync)
}
// ---------------------------------------------------------------------------
// Live trigger innards (the item is static DATA; these subscribe).
// ---------------------------------------------------------------------------
function useHealth() {
const gateway = useValue(host.state.gateway)
const readiness = useValue($readiness)
return {
connecting: gateway === 'connecting',
open: gateway === 'open',
readiness,
ready: gateway === 'open' && readiness?.ready === true
}
}
function PillIcon() {
const { connecting, open, ready } = useHealth()
return (
<span
className={cn(
'inline-flex',
ready ? 'text-(--ui-text-tertiary)' : open || connecting ? 'text-amber-500' : 'text-red-400'
)}
>
{ready ? <icons.Activity className="size-3" /> : <icons.AlertCircle className="size-3" />}
</span>
)
}
function PillDetail() {
const { t } = useI18n()
const copy = t.shell.statusbar
const { connecting, open, readiness, ready } = useHealth()
const detail = open
? ready
? copy.gatewayReady
: readiness
? copy.gatewayNeedsSetup
: copy.gatewayChecking
: connecting
? copy.gatewayConnecting
: copy.gatewayOffline
return <>{detail}</>
}
// ---------------------------------------------------------------------------
// The menu panel — the real GatewayMenuPanel, rebuilt on SDK doors.
// ---------------------------------------------------------------------------
/** Live gui-log tail while the popover is mounted (i.e. open). */
function useGatewayLogTail(): string[] {
const [lines, setLines] = useState<string[]>([])
useEffect(() => {
let cancelled = false
const load = () =>
host
.logs({ file: 'gui', lines: LOG_TAIL })
.then(res => {
if (!cancelled) {
setLines(
res.lines
.map(line => line.trim())
.filter(line => line && !LOG_NOISE_RE.test(line))
.slice(-LOG_VISIBLE)
)
}
})
.catch(() => undefined)
void load()
const timer = window.setInterval(load, LOG_POLL_MS)
return () => {
cancelled = true
window.clearInterval(timer)
}
}, [])
return lines
}
function Section({ children, className }: { children: ReactNode; className?: string }) {
return <div className={cn('border-t border-border/50 px-3 py-2', className)}>{children}</div>
}
function SectionLabel({ children }: { children: string }) {
return (
<div className="text-[0.62rem] font-semibold uppercase tracking-[0.14em] text-muted-foreground/80">{children}</div>
)
}
function GatewayMenuPanel({ onClose }: { onClose: () => void }) {
const { t } = useI18n()
const copy = t.shell.gatewayMenu
const gateway = useValue(host.state.gateway)
const { readiness, ready } = useHealth()
const [snapshot, setSnapshot] = useState<null | StatusResponse>(null)
const recentLogs = useGatewayLogTail()
useEffect(() => {
void host
.status()
.then(setSnapshot)
.catch(() => undefined)
}, [])
const openSystem = () => {
onClose()
host.navigate(SYSTEM_PANEL_ROUTE)
}
const restart = () => {
onClose()
void host.restartGateway().catch(() => undefined)
}
const gatewayOpen = gateway === 'open'
const gatewayConnecting = gateway === 'connecting'
const connectionLabel = gatewayOpen
? copy.connected
: gatewayConnecting
? copy.connecting
: prettyState(gateway || copy.offline)
const inferenceLabel = gatewayOpen
? readiness?.ready
? copy.inferenceReady
: readiness
? copy.inferenceNotReady
: copy.checkingInference
: copy.disconnected
const platforms = Object.entries(snapshot?.gateway_platforms || {}).sort(([l], [r]) => l.localeCompare(r))
// 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-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={ready ? 'good' : gatewayOpen ? 'warn' : 'bad'} />
{inferenceLabel}
</span>
</div>
<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"
>
<icons.RefreshCw />
</Button>
</Tip>
<Tip label={copy.openSystem}>
<Button
aria-label={copy.openSystem}
className="text-muted-foreground hover:text-foreground"
onClick={openSystem}
size="icon-xs"
variant="ghost"
>
<icons.LayoutDashboard />
</Button>
</Tip>
</div>
</div>
{readiness?.reason && (
<Section className="text-xs text-muted-foreground">
<div className="line-clamp-3">{readiness.reason}</div>
</Section>
)}
{recentLogs.length > 0 && (
<Section>
<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>
</Section>
)}
{platforms.length > 0 && (
<Section>
<SectionLabel>{copy.messagingPlatforms}</SectionLabel>
<ul className="mt-1.5 space-y-1">
{platforms.map(([name, platform]) => (
<li className="flex items-center justify-between gap-2 text-xs" key={name}>
<span className="truncate capitalize">{name}</span>
<span className="flex items-center gap-1.5 text-[0.66rem] text-muted-foreground">
<StatusDot tone={PLATFORM_TONE[platform.state] || 'muted'} />
{prettyState(platform.state)}
</span>
</li>
))}
</ul>
</Section>
)}
</div>
)
}
function PillLabel() {
const { t } = useI18n()
return <>{t.shell.statusbar.gateway}</>
}
// ---------------------------------------------------------------------------
const plugin: HermesPlugin = {
id: 'gateway-pill',
name: 'Gateway Pill',
register(ctx) {
startReadinessPoll()
// Declarative menu item — the app's own trigger/popover chrome renders it
// (portal, w-72, side=top), the plugin supplies live innards + the panel.
ctx.register({
id: 'pill',
area: 'statusBar.right',
order: 90,
data: {
icon: <PillIcon />,
id: 'gateway-pill',
label: <PillLabel />,
detail: <PillDetail />,
menuClassName: 'w-72',
menuContent: (close: () => void) => <GatewayMenuPanel onClose={close} />,
variant: 'menu'
} satisfies StatusbarItem
})
}
}
export default plugin