Created
September 11, 2026 18:55
-
-
Save pwwang/b6466ffad2b0f9510cc4e53eed0baad7 to your computer and use it in GitHub Desktop.
quote-comment desktop plugin - slimmed reference example (hermes-example-plugins proposal)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * quote-comment (reference) — right-click a text selection inside a message: | |
| * Copy it, or queue a quote+comment the composer middleware appends to the | |
| * next outgoing message (Submit queues; the middleware injects on send). | |
| * | |
| * Reference surfaces, rationale at each code site: window-capture | |
| * contextmenu + selection/data-role scope on target AND anchorNode (the app | |
| * stops propagation at window capture; same-target listeners still run); | |
| * 'data-hermes-context-menu-trigger' menu-flash stamps (cleared in the | |
| * contextmenu handler + 600ms timer, never on mouseup); position:fixed | |
| * overlays from a STATUSBAR_AREAS.right contribution (always-mounted portal); | |
| * pointerdown row activation + compat-mousedown consumption; globalThis | |
| * listener swap + onDispose cleanup; composer middleware: { ...draft, text } | |
| * rewrite / null cancel / throw pass-through. | |
| */ | |
| import { COMPOSER_AREAS, STATUSBAR_AREAS, atom, useValue, Button, Textarea } from '@hermes/plugin-sdk' | |
| import { Fragment, jsx, jsxs } from 'react/jsx-runtime' | |
| import { useState } from 'react' | |
| const menuAtom = atom({ open: false, x: 0, y: 0, text: '' }) // open menu: position + selected text | |
| const dialogAtom = atom({ open: false, quote: '' }) // comment dialog | |
| const pendingAtom = atom([]) // queued {quote, comment} — drained by the middleware on send | |
| let ctxRef = null // ctx is only reachable inside register(); stashed for os.writeClipboard | |
| // Consume the first mousedown after a row activation (the menu unmounts on | |
| // pointerdown; the compat mousedown would closeAll the dialog just opened). | |
| let suppressDismiss = false | |
| let suppressResetTimer = 0 | |
| function armDismissSuppression() { | |
| suppressDismiss = true | |
| clearTimeout(suppressResetTimer) | |
| suppressResetTimer = setTimeout(() => { suppressDismiss = false }, 1500) | |
| } | |
| /** '> ' per PARAGRAPH (blank-line-separated block); comment on its own paragraph. */ | |
| function formatCommentBlock(quote, comment) { | |
| const quoted = quote.split(/\n\s*\n/).map(p => p.trim()).filter(Boolean).map(p => `> ${p}`).join('\n\n') | |
| return comment ? `${quoted}\n\n${comment}` : quoted | |
| } | |
| // Roots stamped with the app's own context-menu opt-out attribute during a | |
| // right-click gesture so its window-capture handler skips them — no flash, | |
| // no double menu. Cleared in the contextmenu handler or by the 600ms safety | |
| // timer; NOT on mouseup (Windows fires contextmenu after mouseup). | |
| const markedRoots = new Set() | |
| let markClearTimer = 0 | |
| function markRoot(root) { | |
| if (root && !markedRoots.has(root)) { | |
| root.setAttribute('data-hermes-context-menu-trigger', '') | |
| markedRoots.add(root) | |
| } | |
| clearTimeout(markClearTimer) | |
| markClearTimer = setTimeout(clearMarks, 600) | |
| } | |
| function clearMarks() { | |
| clearTimeout(markClearTimer) | |
| for (const root of markedRoots) root.removeAttribute('data-hermes-context-menu-trigger') | |
| markedRoots.clear() | |
| } | |
| function selectionContext() { | |
| const selection = window.getSelection() | |
| const text = selection ? selection.toString().trim() : '' | |
| return text ? { selection, text } : null | |
| } | |
| /** Nearest message root (the data-role scope) of a node. */ | |
| function messageRootOf(node) { | |
| const el = node instanceof Element ? node : node?.parentElement | |
| return el?.closest?.('[data-role="user"], [data-role="assistant"]') ?? null | |
| } | |
| /** Theme vars only — never hardcoded colors. */ | |
| const SURFACE = { position: 'fixed', zIndex: 'var(--z-over-modal)', background: 'var(--ui-bg-elevated)', border: '1px solid var(--ui-stroke-secondary)', borderRadius: 6, color: 'var(--ui-text-primary)', fontSize: 13, fontFamily: 'inherit' } | |
| // Statusbar contribution = always-mounted portal for the fixed overlays. | |
| function PluginRoot() { | |
| const menu = useValue(menuAtom) | |
| const dialog = useValue(dialogAtom) | |
| return jsxs(Fragment, { | |
| children: [ | |
| menu.open && jsx(ContextMenuCard, { key: 'menu', x: menu.x, y: menu.y, text: menu.text }), | |
| dialog.open && jsx(CommentDialog, { key: 'dialog', quote: dialog.quote }) | |
| ] | |
| }) | |
| } | |
| const closeMenu = () => menuAtom.set({ open: false, x: 0, y: 0, text: '' }) | |
| const closeDialog = () => dialogAtom.set({ open: false, quote: '' }) | |
| const closeAll = () => { closeMenu(); closeDialog() } | |
| /** Rows activate on POINTERDOWN: the app's Radix DismissableLayer | |
| * preventDefaults outside pointerdowns, killing the subsequent click. */ | |
| function MenuRow({ label, onSelect }) { | |
| const [hover, setHover] = useState(false) | |
| return jsx('button', { | |
| type: 'button', onPointerDown: onSelect, | |
| onMouseEnter: () => setHover(true), onMouseLeave: () => setHover(false), | |
| style: { display: 'block', width: '100%', padding: '5px 10px', border: 0, borderRadius: 4, textAlign: 'left', color: 'var(--ui-text-primary)', fontSize: 13, fontFamily: 'inherit', cursor: 'pointer', background: hover ? 'var(--ui-control-hover-background)' : 'transparent' }, | |
| children: label | |
| }) | |
| } | |
| function ContextMenuCard({ x, y, text }) { | |
| const left = Math.min(x, window.innerWidth - 176) | |
| const top = Math.min(y, window.innerHeight - 100) | |
| return jsxs('div', { | |
| 'data-qc': 'menu', | |
| style: { ...SURFACE, left, top, minWidth: 160, padding: 4 }, | |
| children: [ | |
| jsx(MenuRow, { label: 'Copy', onSelect: () => { armDismissSuppression(); void ctxRef.os.writeClipboard(text); closeMenu() } }), | |
| jsx(MenuRow, { label: 'Comment', onSelect: () => { armDismissSuppression(); dialogAtom.set({ open: true, quote: text }); closeMenu() } }) | |
| ] | |
| }) | |
| } | |
| function CommentDialog({ quote }) { | |
| const [comment, setComment] = useState('') | |
| const truncated = quote.length > 200 ? `${quote.slice(0, 200)}…` : quote | |
| const submit = () => { | |
| pendingAtom.set([...pendingAtom.get(), { quote, comment: comment.trim() }]) // queue on Submit | |
| window.getSelection()?.removeAllRanges() // so the quote no longer floats over the chat | |
| closeDialog() | |
| } | |
| return jsxs('div', { | |
| 'data-qc': 'dialog', | |
| style: { ...SURFACE, left: '50%', top: '45%', transform: 'translate(-50%, -50%)', width: 'min(440px, calc(100vw - 32px))', padding: 14 }, | |
| children: [ | |
| jsx('div', { | |
| title: quote, | |
| style: { maxHeight: 120, overflow: 'auto', padding: 8, marginBottom: 10, border: '1px solid var(--ui-stroke-secondary)', borderRadius: 4, color: 'var(--ui-text-secondary)', fontSize: 12, lineHeight: 1.5, whiteSpace: 'pre-wrap', overflowWrap: 'anywhere' }, | |
| children: truncated | |
| }), | |
| jsx(Textarea, { | |
| // Callback-ref focus beats autoFocus, which loses the race against the | |
| // compat mousedown's focus-steal (preventDefaulted in onMouseDown). | |
| ref: el => { el?.focus() }, | |
| value: comment, onChange: e => setComment(e.target.value), placeholder: 'Add a comment…', | |
| style: { width: '100%', minHeight: 72, resize: 'vertical', fontSize: 13 }, | |
| onKeyDown: e => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); submit() } } // Escape: global keydown | |
| }), | |
| jsxs('div', { | |
| style: { display: 'flex', justifyContent: 'flex-end', gap: 8, marginTop: 10 }, | |
| children: [ | |
| jsx(Button, { type: 'button', variant: 'secondary', onClick: closeDialog, children: 'Cancel' }), | |
| jsx(Button, { type: 'button', variant: 'default', onClick: submit, children: 'Submit' }) | |
| ] | |
| }) | |
| ] | |
| }) | |
| } | |
| /** Queued blocks drain on the next send; blank line before the block when | |
| * the draft is non-empty. */ | |
| function middlewareHandler(draft) { | |
| const pending = pendingAtom.get() | |
| if (pending.length === 0) return draft | |
| const block = pending.map(({ quote, comment }) => formatCommentBlock(quote, comment)).join('\n\n') | |
| pendingAtom.set([]) | |
| const base = draft.text ? draft.text.trimEnd() : '' | |
| return { ...draft, text: base ? `${base}\n\n${block}` : block } | |
| } | |
| // Hot reload: a re-evaluated plugin removes the previous incarnation's | |
| // listener before adding its own — no stacking on dead closures. | |
| const KEY_CONTEXTMENU = '__qc_ctxmenu_handler' | |
| const KEY_MOUSEDOWN = '__qc_mousedown_handler' | |
| const KEY_KEYDOWN = '__qc_keydown_handler' | |
| const KEY_SCROLL = '__qc_scroll_handler' | |
| function bindOnce(key, target, type, fn, capture) { | |
| const previous = globalThis[key] | |
| if (previous) target.removeEventListener(type, previous, capture) | |
| target.addEventListener(type, fn, capture) | |
| globalThis[key] = fn | |
| } | |
| function unbind(key, target, type, capture) { | |
| const fn = globalThis[key] | |
| if (fn) { target.removeEventListener(type, fn, capture); delete globalThis[key] } | |
| } | |
| export default { | |
| id: 'quote-comment', | |
| name: 'Quote & Comment', | |
| register(ctx) { | |
| ctxRef = ctx | |
| ctx.register({ id: 'portal', area: STATUSBAR_AREAS.right, order: 130, render: () => jsx(PluginRoot, {}) }) | |
| ctx.register({ id: 'middleware', area: COMPOSER_AREAS.middleware, data: { handler: middlewareHandler } }) | |
| // WINDOW capture, not document: the app's AppContextMenu listens on window | |
| // capture and stopPropagation()s every right-click, killing document-level | |
| // listeners — stopPropagation does NOT stop same-target ones (that is | |
| // stopImmediatePropagation), so ours runs after the app's, which is what | |
| // makes the trigger-stamp suppression work. | |
| const onContextMenu = event => { | |
| clearMarks() // the app has decided on this gesture — drop the stamps | |
| const info = selectionContext() | |
| // No selection or right-click outside message text: stand down (also | |
| // closes any stale menu), leave the gesture to the app. | |
| if (!info || !messageRootOf(event.target) || !messageRootOf(info.selection.anchorNode)) { | |
| closeAll() | |
| return | |
| } | |
| event.preventDefault() // suppressing the app menu means WE must offer Copy | |
| event.stopPropagation() | |
| menuAtom.set({ open: true, x: event.clientX, y: event.clientY, text: info.text }) | |
| } | |
| // Mousedowns inside a surface ('[data-qc]') or on detached nodes are | |
| // ignored — a row unmounts inside its pointerdown handler, so the compat | |
| // mousedown targets a dead node (closest('[data-qc]') is null); reading it | |
| // as "outside" would close the dialog just opened (consumed via | |
| // suppressDismiss first). | |
| const onMouseDown = event => { | |
| if (suppressDismiss) { | |
| suppressDismiss = false | |
| event.preventDefault() // else focus moves to the re-hit-tested node, stealing it from the textarea | |
| return | |
| } | |
| if (event.button === 2) { // right-button press over a message selection: | |
| const info = selectionContext() // stamp the roots BEFORE the contextmenu | |
| if (info && messageRootOf(event.target) && messageRootOf(info.selection.anchorNode)) { | |
| markRoot(messageRootOf(event.target)) // event fires, so the app's | |
| markRoot(messageRootOf(info.selection.anchorNode)) // handler skips them | |
| } | |
| } | |
| const target = event.target instanceof Element ? event.target : null | |
| if (!target || !target.isConnected || target.closest('[data-qc]')) return | |
| closeAll() | |
| } | |
| const onKeyDown = event => { | |
| if (event.key !== 'Escape') return | |
| if (dialogAtom.get().open) closeDialog() | |
| else closeAll() | |
| } | |
| // A scroll outside a surface would leave the fixed menu behind (the | |
| // capture-phase target identifies the scroller; inside-scrolls are fine). | |
| const onScroll = event => { | |
| if (event.target instanceof Element && event.target.closest('[data-qc]')) return | |
| closeAll() | |
| } | |
| bindOnce(KEY_CONTEXTMENU, window, 'contextmenu', onContextMenu, true) | |
| bindOnce(KEY_MOUSEDOWN, window, 'mousedown', onMouseDown, true) | |
| bindOnce(KEY_KEYDOWN, window, 'keydown', onKeyDown, true) | |
| bindOnce(KEY_SCROLL, window, 'scroll', onScroll, true) | |
| // Full unload (disable/remove) gives the app its native menu back. | |
| ctx.onDispose(() => { | |
| clearMarks() | |
| unbind(KEY_CONTEXTMENU, window, 'contextmenu', true) | |
| unbind(KEY_MOUSEDOWN, window, 'mousedown', true) | |
| unbind(KEY_KEYDOWN, window, 'keydown', true) | |
| unbind(KEY_SCROLL, window, 'scroll', true) | |
| }) | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment