mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): SDK — useGrabScroll export + dogfood plugin touch-ups
This commit is contained in:
parent
79e7adae2d
commit
5c4d1e1ea2
5 changed files with 614 additions and 0 deletions
77
apps/desktop/src/hooks/use-grab-scroll.ts
Normal file
77
apps/desktop/src/hooks/use-grab-scroll.ts
Normal 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 }
|
||||
}
|
||||
121
apps/desktop/src/plugins/example/plugin.tsx
Normal file
121
apps/desktop/src/plugins/example/plugin.tsx
Normal 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
|
||||
375
apps/desktop/src/plugins/gateway-pill/plugin.tsx
Normal file
375
apps/desktop/src/plugins/gateway-pill/plugin.tsx
Normal 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
|
||||
38
apps/desktop/src/plugins/hello-runtime/plugin.runtime.js
Normal file
38
apps/desktop/src/plugins/hello-runtime/plugin.runtime.js
Normal 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, {})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue