feat(desktop): SDK — useGrabScroll export + dogfood plugin touch-ups

This commit is contained in:
Brooklyn Nicholson 2026-07-15 14:11:08 -04:00
parent 79e7adae2d
commit 5c4d1e1ea2
5 changed files with 614 additions and 0 deletions

View file

@ -0,0 +1,77 @@
import { type MouseEvent as ReactMouseEvent, type RefObject, useState } from 'react'
// Grab-to-pan for overflow containers — the shared primitive behind "scrub the
// board/timeline by dragging its background" (kanban lanes, trace waterfalls,
// wide tables). Sibling of lib/trackpad-gestures.ts: that file classifies
// wheel gestures, this one owns pointer-drag panning, so surfaces stop
// re-deriving the same interaction (the dashboard kanban and the agent-traces
// waterfall each hand-rolled a copy).
//
// Behavior contract:
// - drags translate scrollLeft/scrollTop (both axes, whichever overflow);
// - interactive targets never start a pan (buttons, inputs, links,
// [draggable] cards keep their own drag semantics);
// - the native scrollbar gutters stay untouched as the fallback affordance;
// - selection can't start mid-pan (preventDefault on move), and window
// blur/mouseup always end it.
const BLOCKED_TARGETS = 'button,input,textarea,select,a,[role="button"],[draggable="true"]'
const SCROLLBAR_GUTTER_PX = 16
export interface GrabScroll {
/** True while a pan is in flight — drive `cursor-grabbing` styling. */
grabbing: boolean
/** Spread onto the scroll container. */
onMouseDown: (event: ReactMouseEvent) => void
}
export function useGrabScroll(ref: RefObject<HTMLElement | null>): GrabScroll {
const [grabbing, setGrabbing] = useState(false)
const onMouseDown = (event: ReactMouseEvent) => {
const el = ref.current
if (event.button !== 0 || !el) {
return
}
const canX = el.scrollWidth > el.clientWidth
const canY = el.scrollHeight > el.clientHeight
if ((!canX && !canY) || (event.target as HTMLElement).closest(BLOCKED_TARGETS)) {
return
}
const rect = el.getBoundingClientRect()
if (
(canX && event.clientY >= rect.bottom - SCROLLBAR_GUTTER_PX) ||
(canY && event.clientX >= rect.right - SCROLLBAR_GUTTER_PX)
) {
return
}
const start = { left: el.scrollLeft, top: el.scrollTop, x: event.clientX, y: event.clientY }
setGrabbing(true)
const onMove = (move: MouseEvent) => {
el.scrollLeft = start.left - (move.clientX - start.x)
el.scrollTop = start.top - (move.clientY - start.y)
move.preventDefault()
}
const stop = () => {
setGrabbing(false)
window.removeEventListener('mousemove', onMove)
window.removeEventListener('mouseup', stop)
window.removeEventListener('blur', stop)
}
window.addEventListener('mousemove', onMove)
window.addEventListener('mouseup', stop, { once: true })
window.addEventListener('blur', stop, { once: true })
event.preventDefault()
}
return { grabbing, onMouseDown }
}

View file

@ -0,0 +1,121 @@
/**
* Example plugin the authoring + publishing reference. A folder under
* `src/plugins/` with a `plugin.tsx` that default-exports a `HermesPlugin` is
* all it takes; `discoverBundledPlugins()` finds and registers it (no import,
* no registry edit). Delete this folder and everything below is gone.
*
* The ONLY import surface is `@hermes/plugin-sdk` (lint-enforced) the
* vscode-module model. This one plugin dogfoods the whole authoring kit:
* - `render()` contribution full stateful React in a statusbar slot;
* - `ctx.storage` the count survives reloads (namespaced persistence);
* - `host.onEvent('*')` live gateway stream, counted in the tooltip;
* - PALETTE + KEYBINDS contracts "Example: Reset click counter" in K
* and as a rebindable (default-unbound) action in the keybind panel;
* - plugin-local `atom` + `useValue` module state, leaf subscription;
* - `haptic` / `host.notify` / `Tip` / `cn` the design language.
*/
import {
atom,
cn,
haptic,
type HermesPlugin,
host,
type KeybindContribution,
KEYBINDS_AREA,
PALETTE_AREA,
type PaletteContribution,
STATUSBAR_AREAS,
Tip,
useValue
} from '@hermes/plugin-sdk'
const $clicks = atom(0)
const $events = atom(0)
function ClickCounter() {
const count = useValue($clicks)
const events = useValue($events)
const gateway = useValue(host.state.gateway)
return (
<Tip label={`Example plugin — gateway ${gateway}, ${events} events heard`}>
<button
className={cn(
'inline-flex h-full items-center gap-1 rounded-none px-1.5 text-[0.6875rem] tabular-nums transition-colors',
'text-(--ui-text-tertiary) hover:bg-(--chrome-action-hover) hover:text-foreground',
count > 0 && 'text-foreground'
)}
onClick={() => {
haptic('tap')
// Imperative read in the handler ($atom.get()), reactive read in the
// render (useValue) — never a stale closure.
const next = $clicks.get() + 1
$clicks.set(next)
if (next % 10 === 0) {
host.notify({ kind: 'success', message: `Example plugin: ${next} clicks!` })
}
}}
type="button"
>
<span aria-hidden></span>
<span>{count === 0 ? 'click me' : `clicked ${count}×`}</span>
</button>
</Tip>
)
}
const plugin: HermesPlugin = {
id: 'example',
name: 'Example Plugin',
register(ctx) {
// Persisted count: hydrate once, write through on every change.
$clicks.set(ctx.storage.get('clicks', 0))
$clicks.listen(clicks => ctx.storage.set('clicks', clicks))
// Hear the live gateway stream (deltas, lifecycle, tools — everything).
host.onEvent('*', () => $events.set($events.get() + 1))
const reset = () => {
$clicks.set(0)
host.notify({ kind: 'info', message: 'Example plugin: counter reset' })
}
// Provenance (source: 'plugin:example') and the namespaced registry ids
// (example:counter, …) are stamped by ctx — authors write plain
// contributions. The shared `example.reset` ACTION id links the palette
// row's hotkey hint to the keybind panel's live binding.
ctx.registerMany([
{
id: 'counter',
area: STATUSBAR_AREAS.right,
order: 100,
render: () => <ClickCounter />
},
{
id: 'reset',
area: PALETTE_AREA,
data: {
id: 'example.reset',
action: 'example.reset',
label: 'Example: Reset click counter',
keywords: ['example', 'plugin', 'counter'],
run: reset
} satisfies PaletteContribution
},
{
id: 'reset',
area: KEYBINDS_AREA,
data: {
id: 'example.reset',
label: 'Example: Reset click counter',
defaults: [],
run: reset
} satisfies KeybindContribution
}
])
}
}
export default plugin

View file

@ -0,0 +1,375 @@
/**
* 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

View file

@ -0,0 +1,38 @@
/**
* Runtime-loaded example this file is NOT bundled as a module: it ships as
* raw text (`?raw`) and goes through the real runtime pipeline (specifier
* rewrite -> SDK/react shim blobs -> blob import -> register). Plain ESM js
* with `jsx()` calls exactly what an agent (or a compiler) writes into
* `~/.hermes/desktop-plugins/<name>/plugin.js`.
*/
import { cn, host, Tip, useValue } from '@hermes/plugin-sdk'
import { jsx, jsxs } from 'react/jsx-runtime'
function RuntimeChip() {
const gateway = useValue(host.state.gateway)
return jsx(Tip, {
label: `Loaded at RUNTIME through blob import + SDK injection (gateway: ${gateway})`,
children: jsxs('span', {
className: cn(
'inline-flex h-full items-center gap-1 px-1.5 text-[0.6875rem]',
'text-(--ui-text-tertiary)'
),
children: [jsx('span', { 'aria-hidden': true, children: '⚡' }), jsx('span', { children: 'runtime' })]
})
})
}
export default {
id: 'hello-runtime',
name: 'Hello Runtime',
register(ctx) {
ctx.register({
id: 'chip',
area: 'statusBar.right',
order: 110,
render: () => jsx(RuntimeChip, {})
})
}
}

View file

@ -200,6 +200,9 @@ export type {
* id with your plugin slug (`kanban:board-switcher`). */
export { Contribute, type ContributeProps } from '@/contrib/react/contribute'
export type { Contribution } from '@/contrib/types'
/** Grab-to-pan for overflow containers (boards, timelines, wide tables)
* the shared scrub primitive; don't hand-roll drag-to-scroll. */
export { type GrabScroll, useGrabScroll } from '@/hooks/use-grab-scroll'
/** Localized copy. `useI18n` reuses the app's strings; `usePluginI18n(id)` +
* `ctx.i18n.register` let a plugin ship its OWN locale bundles, scoped like
* `ctx.storage` and resolved against the app's active locale no core edit. */