From 5c4d1e1ea2797d66d20c2e250ff223dd46a0d055 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Wed, 15 Jul 2026 14:11:08 -0400 Subject: [PATCH] =?UTF-8?q?feat(desktop):=20SDK=20=E2=80=94=20useGrabScrol?= =?UTF-8?q?l=20export=20+=20dogfood=20plugin=20touch-ups?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- apps/desktop/src/hooks/use-grab-scroll.ts | 77 ++++ apps/desktop/src/plugins/example/plugin.tsx | 121 ++++++ .../src/plugins/gateway-pill/plugin.tsx | 375 ++++++++++++++++++ .../plugins/hello-runtime/plugin.runtime.js | 38 ++ apps/desktop/src/sdk/index.ts | 3 + 5 files changed, 614 insertions(+) create mode 100644 apps/desktop/src/hooks/use-grab-scroll.ts create mode 100644 apps/desktop/src/plugins/example/plugin.tsx create mode 100644 apps/desktop/src/plugins/gateway-pill/plugin.tsx create mode 100644 apps/desktop/src/plugins/hello-runtime/plugin.runtime.js diff --git a/apps/desktop/src/hooks/use-grab-scroll.ts b/apps/desktop/src/hooks/use-grab-scroll.ts new file mode 100644 index 00000000000..379fed3c289 --- /dev/null +++ b/apps/desktop/src/hooks/use-grab-scroll.ts @@ -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): 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 } +} diff --git a/apps/desktop/src/plugins/example/plugin.tsx b/apps/desktop/src/plugins/example/plugin.tsx new file mode 100644 index 00000000000..22f6f9ba9d4 --- /dev/null +++ b/apps/desktop/src/plugins/example/plugin.tsx @@ -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 ( + + + + ) +} + +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: () => + }, + { + 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 diff --git a/apps/desktop/src/plugins/gateway-pill/plugin.tsx b/apps/desktop/src/plugins/gateway-pill/plugin.tsx new file mode 100644 index 00000000000..60064f1c53b --- /dev/null +++ b/apps/desktop/src/plugins/gateway-pill/plugin.tsx @@ -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 = { + 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) + +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 ( + + {ready ? : } + + ) +} + +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([]) + + 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
{children}
+} + +function SectionLabel({ children }: { children: string }) { + return ( +
{children}
+ ) +} + +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) + 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(null) + + useEffect(() => { + const el = logScrollRef.current + + if (el) { + el.scrollTop = el.scrollHeight + } + }, [recentLogs]) + + return ( +
+
+
+ + + {connectionLabel} + + + + {inferenceLabel} + +
+
+ + + + + + +
+
+ + {readiness?.reason && ( +
+
{readiness.reason}
+
+ )} + + {recentLogs.length > 0 && ( +
+
+ {copy.recentActivity} + +
+ + {recentLogs.map(trimLogLine).join('\n')} + +
+ )} + + {platforms.length > 0 && ( +
+ {copy.messagingPlatforms} +
    + {platforms.map(([name, platform]) => ( +
  • + {name} + + + {prettyState(platform.state)} + +
  • + ))} +
+
+ )} +
+ ) +} + +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: , + id: 'gateway-pill', + label: , + detail: , + menuClassName: 'w-72', + menuContent: (close: () => void) => , + variant: 'menu' + } satisfies StatusbarItem + }) + } +} + +export default plugin diff --git a/apps/desktop/src/plugins/hello-runtime/plugin.runtime.js b/apps/desktop/src/plugins/hello-runtime/plugin.runtime.js new file mode 100644 index 00000000000..6647138e1fe --- /dev/null +++ b/apps/desktop/src/plugins/hello-runtime/plugin.runtime.js @@ -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//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, {}) + }) + } +} diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 8d28af45238..ae7069e931f 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -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. */