From 91337e5789c4ab1e67c2993b843459828b67a18a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 11:26:12 -0600 Subject: [PATCH 01/28] =?UTF-8?q?fix(desktop):=20=E2=8C=981=20/=20?= =?UTF-8?q?=E2=8C=83Tab=20return=20to=20the=20chat=20from=20a=20full-page?= =?UTF-8?q?=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hitting ⌘1 (or cycling ⌃Tab onto the main tab) while Capabilities / Messaging / Artifacts covered the workspace looked dead: the workspace pane was already the zone's active tab behind the page, so fronting it changed nothing on screen. activateTreeTabSlot / cycleTreeTabInFocusedZone now return the activated pane id, and the keybind handlers route back to the loaded session (or the new-chat draft) when the landing pane is the workspace under a full page — the same rule openSession already applies. --- apps/desktop/src/app/hooks/use-keybinds.ts | 43 ++++++++++++++++--- .../pane-shell/tree/hovered-zone-tabs.test.ts | 16 +++---- .../src/components/pane-shell/tree/store.ts | 21 +++++---- .../pane-shell/tree/tab-slot-shown.test.ts | 14 +++--- 4 files changed, 64 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 32d9caaba2..fa41cad238 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -42,7 +42,7 @@ import { } from '@/store/profile' import { openFolderAsProject, requestNewWorktree } from '@/store/projects' import { toggleReview } from '@/store/review' -import { setModelPickerOpen } from '@/store/session' +import { $selectedStoredSessionId, setModelPickerOpen } from '@/store/session' import { reopenLastClosedTile } from '@/store/session-states' import { $switcherOpen, @@ -62,11 +62,13 @@ import { useTheme } from '@/themes/context' import { requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus' import { openSession } from '../open-session' import { + $workspaceIsPage, AGENTS_ROUTE, ARTIFACTS_ROUTE, CRON_ROUTE, MESSAGING_ROUTE, navigateToWorkspacePage, + NEW_CHAT_ROUTE, PROFILES_ROUTE, sessionRoute, SETTINGS_ROUTE, @@ -99,11 +101,29 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { const profileSwitchHandlers: HandlerMap = {} + // A tab key that lands on the WORKSPACE tab while a full page (skills / + // messaging / artifacts / a plugin route) covers it must also route back to + // the chat: the workspace pane is already the zone's active tab behind the + // page, so fronting it alone changes nothing on screen and the key reads + // dead. Mirrors `openSession`'s full-page rule — only a route change puts + // the chat back. + const leavePageForWorkspaceChat = (paneId: null | string) => { + if (paneId === 'workspace' && $workspaceIsPage.get()) { + const selected = $selectedStoredSessionId.get() + + navigate(selected ? sessionRoute(selected) : NEW_CHAT_ROUTE) + } + } + for (let slot = 1; slot <= PROFILE_SLOT_COUNT; slot += 1) { // ⌘1…⌘9 switch the FOCUSED zone's tab when it's a real tab strip; only a // single-pane (or unfocused) layout falls through to the profile switch. profileSwitchHandlers[`profile.switch.${slot}`] = () => { - if (!activateTreeTabSlot(slot)) { + const pane = activateTreeTabSlot(slot) + + if (pane) { + leavePageForWorkspaceChat(pane) + } else { switchProfileToSlot(slot) } } @@ -132,6 +152,19 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { goToSession(openOrAdvanceSwitcher(direction)) } + // ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus + // falls through to the recent-session switcher. Landing on the workspace + // under a full page routes back to the chat (same as ⌘1). + const cycleTab = (direction: 1 | -1) => { + const pane = cycleTreeTabInFocusedZone(direction) + + if (pane) { + leavePageForWorkspaceChat(pane) + } else { + stepSession(direction) + } + } + const showFiles = () => { setFileBrowserOpen(true) setTerminalTakeover(false) @@ -170,10 +203,8 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { }, 'session.newTab': () => deps.openNewSessionTab(), 'session.newWindow': () => void openNewWindow(), - // ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus - // falls through to the recent-session switcher. - 'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)), - 'session.prev': () => void (cycleTreeTabInFocusedZone(-1) || stepSession(-1)), + 'session.next': () => cycleTab(1), + 'session.prev': () => cycleTab(-1), ...sessionSlotHandlers, 'session.focusSearch': requestSessionSearchFocus, 'session.togglePin': deps.toggleSelectedPin, diff --git a/apps/desktop/src/components/pane-shell/tree/hovered-zone-tabs.test.ts b/apps/desktop/src/components/pane-shell/tree/hovered-zone-tabs.test.ts index e508cd258b..18be17e015 100644 --- a/apps/desktop/src/components/pane-shell/tree/hovered-zone-tabs.test.ts +++ b/apps/desktop/src/components/pane-shell/tree/hovered-zone-tabs.test.ts @@ -48,14 +48,14 @@ describe('hovered zone retargets the tab verbs', () => { tree.noteActiveTreeGroup('grp-main') tree.noteHoveredTreeGroup('grp-side') - expect(tree.activateTreeTabSlot(2)).toBe(true) + expect(tree.activateTreeTabSlot(2)).toBeTruthy() expect(activeOf('grp-side')).toBe('session-tile:c') // The focused zone is untouched — the pointer won the target, not both. expect(activeOf('grp-main')).toBe('workspace') // Same key, pointer moved: the other zone's slot 2. tree.noteHoveredTreeGroup('grp-main') - expect(tree.activateTreeTabSlot(2)).toBe(true) + expect(tree.activateTreeTabSlot(2)).toBeTruthy() expect(activeOf('grp-main')).toBe('session-tile:a') }) @@ -66,7 +66,7 @@ describe('hovered zone retargets the tab verbs', () => { tree.noteHoveredTreeGroup('grp-main') tree.noteHoveredTreeGroup(null) - expect(tree.activateTreeTabSlot(2)).toBe(true) + expect(tree.activateTreeTabSlot(2)).toBeTruthy() expect(activeOf('grp-side')).toBe('session-tile:c') expect(activeOf('grp-main')).toBe('workspace') }) @@ -77,7 +77,7 @@ describe('hovered zone retargets the tab verbs', () => { tree.noteActiveTreeGroup('grp-main') tree.noteHoveredTreeGroup('grp-side') - expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true) + expect(tree.cycleTreeTabInFocusedZone(1)).toBeTruthy() expect(activeOf('grp-side')).toBe('session-tile:c') expect(activeOf('grp-main')).toBe('workspace') @@ -98,9 +98,9 @@ describe('hovered zone retargets the tab verbs', () => { tree.noteActiveTreeGroup('grp-side') tree.noteHoveredTreeGroup(null) - expect(tree.activateTreeTabSlot(2)).toBe(true) + expect(tree.activateTreeTabSlot(2)).toBeTruthy() expect(activeOf('grp-side')).toBe('session-tile:c') - expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true) + expect(tree.cycleTreeTabInFocusedZone(1)).toBeTruthy() expect(activeOf('grp-side')).toBe('session-tile:b') }) @@ -112,7 +112,7 @@ describe('hovered zone retargets the tab verbs', () => { tree.noteActiveTreeGroup(null) tree.noteHoveredTreeGroup(null) - expect(tree.activateTreeTabSlot(2)).toBe(true) + expect(tree.activateTreeTabSlot(2)).toBeTruthy() expect(activeOf('grp-main')).toBe('session-tile:a') expect(activeOf('grp-side')).toBe('session-tile:b') }) @@ -138,7 +138,7 @@ describe('hovered zone retargets the tab verbs', () => { tree.noteActiveTreeGroup(null) tree.noteHoveredTreeGroup('grp-files') - expect(tree.activateTreeTabSlot(2)).toBe(true) + expect(tree.activateTreeTabSlot(2)).toBeTruthy() expect(activeOf('grp-main')).toBe('session-tile:a') // ⌘W must not close the file tree from a rung that can't serve it. expect(activeOf('grp-files')).toBe('files') diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index 8399eb36ef..abfb76e34c 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -549,26 +549,29 @@ function shownPanesInGroup(group: { panes: readonly string[] }): string[] { /** ⌘1…⌘9: activate the Nth *visible* tab of the target zone — the first of * hovered / focused / workspace that is a real tab strip (≥2 shown panes). * Pointing at the sidebar (or nothing) therefore still switches main's tabs - * instead of dead-ending. Returns false so the caller falls back to its + * instead of dead-ending. Returns the activated pane id — the caller needs to + * know when the slot landed on the workspace tab (a full page covering it + * must also route back to the chat) — or null so it falls back to its * default (profile switch) when no zone qualifies. */ -export function activateTreeTabSlot(slot: number): boolean { +export function activateTreeTabSlot(slot: number): null | string { const group = tabTargetGroup(candidate => shownPanesInGroup(candidate).length >= 2) const panes = group ? shownPanesInGroup(group) : [] if (!group || slot < 1 || slot > panes.length) { - return false + return null } activateTreePane(group.id, panes[slot - 1]) - return true + return panes[slot - 1] } /** ⌃Tab / ⌃⇧Tab: cycle the target zone's *visible* tabs (wrapping) — the first * of hovered / focused / workspace that is a chat strip with ≥2 shown tabs. - * Returns false so the caller falls back to the recent-session switcher when - * no zone qualifies. */ -export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean { + * Returns the activated pane id (see `activateTreeTabSlot` — landing on the + * workspace under a full page must route back to the chat), or null so the + * caller falls back to the recent-session switcher when no zone qualifies. */ +export function cycleTreeTabInFocusedZone(direction: 1 | -1): null | string { const group = tabTargetGroup(candidate => { const shown = shownPanesInGroup(candidate) @@ -576,7 +579,7 @@ export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean { }) if (!group) { - return false + return null } const panes = shownPanesInGroup(group) @@ -595,7 +598,7 @@ export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean { setTreeGroupHeaderHidden(group.id, false) } - return true + return nextId } /** Remove a pane from the tree WITHOUT a dismissal record — for surfaces diff --git a/apps/desktop/src/components/pane-shell/tree/tab-slot-shown.test.ts b/apps/desktop/src/components/pane-shell/tree/tab-slot-shown.test.ts index 5d8921a919..85ec5b6c0f 100644 --- a/apps/desktop/src/components/pane-shell/tree/tab-slot-shown.test.ts +++ b/apps/desktop/src/components/pane-shell/tree/tab-slot-shown.test.ts @@ -48,13 +48,13 @@ describe('activateTreeTabSlot indexes shown panes only', () => { it('⌘1 is workspace and ⌘2 is the first SESSION tab when files is hidden', async () => { const { activeOf, tree } = await setup() - expect(tree.activateTreeTabSlot(1)).toBe(true) + expect(tree.activateTreeTabSlot(1)).toBe('workspace') expect(activeOf()).toBe('workspace') - expect(tree.activateTreeTabSlot(2)).toBe(true) + expect(tree.activateTreeTabSlot(2)).toBe('session-tile:a') expect(activeOf()).toBe('session-tile:a') - expect(tree.activateTreeTabSlot(3)).toBe(true) + expect(tree.activateTreeTabSlot(3)).toBe('session-tile:b') expect(activeOf()).toBe('session-tile:b') }) @@ -63,19 +63,19 @@ describe('activateTreeTabSlot indexes shown panes only', () => { // Shown: workspace + A + B → 3. Slot 4 would have been `B` on the raw array // (workspace, files, A, B) before this fix — now it correctly refuses. - expect(tree.activateTreeTabSlot(4)).toBe(false) + expect(tree.activateTreeTabSlot(4)).toBeNull() }) it('⌃Tab cycles only visible chips', async () => { const { activeOf, tree } = await setup() - expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true) + expect(tree.cycleTreeTabInFocusedZone(1)).toBe('session-tile:a') expect(activeOf()).toBe('session-tile:a') - expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true) + expect(tree.cycleTreeTabInFocusedZone(1)).toBe('session-tile:b') expect(activeOf()).toBe('session-tile:b') - expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true) + expect(tree.cycleTreeTabInFocusedZone(1)).toBe('workspace') expect(activeOf()).toBe('workspace') }) }) From 5d24594ab34de8223eafd503dec4b6a250fa0e4a Mon Sep 17 00:00:00 2001 From: seref <1573640+serefyarar@users.noreply.github.com> Date: Sun, 2 Aug 2026 18:53:29 -0400 Subject: [PATCH 02/28] feat(desktop): expose native OS notifications to plugins via ctx.notifyNative MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Desktop plugins can toast in-app (host.notify) but have no sanctioned way to reach the OS notification pipeline the app's own approval/turn alerts use, so a plugin surfacing a genuinely notable background event (e.g. a discovery plugin finding a match) stays invisible once the user steps away from Hermes. Add a curated per-plugin door instead of exporting the raw dispatcher: - ctx.notifyNative({ title, body?, silent? }) on PluginContext — attributed to the plugin id, routed through dispatchNativeNotification so every existing gate applies (master + per-kind prefs, post-connect baseline, away-from-app gating, throttle). - New 'plugin' native-notification kind with its own Settings ▸ Notifications toggle (default on), so users silence plugins without losing app alerts. - New optional `tag` discriminator on the notify payload keys the renderer throttle and main-process cross-window dedupe per plugin, so two plugins can't collapse each other's session-less notifications. Consumer: the Index Network desktop plugin wants background opportunity alerts; anything in ~/.hermes/desktop-plugins gets the same door. --- apps/desktop/electron/main.ts | 2 +- apps/desktop/src/contrib/plugin.test.ts | 14 +++++- apps/desktop/src/contrib/plugin.ts | 7 +++ apps/desktop/src/global.d.ts | 2 + apps/desktop/src/i18n/en.ts | 4 ++ apps/desktop/src/i18n/ja.ts | 4 ++ apps/desktop/src/i18n/types.ts | 2 +- apps/desktop/src/i18n/zh-hant.ts | 4 ++ apps/desktop/src/i18n/zh.ts | 4 ++ apps/desktop/src/sdk/index.ts | 1 + .../src/store/native-notifications.test.ts | 29 +++++++++++ .../desktop/src/store/native-notifications.ts | 48 +++++++++++++++++-- .../references/desktop-plugins.md | 5 ++ .../developer-guide/desktop-plugin-sdk.md | 13 ++++- 14 files changed, 131 insertions(+), 8 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 200ecb5fed..2f5340d6e9 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -10333,7 +10333,7 @@ ipcMain.handle('hermes:notify', (_event, payload) => { // kind+session can arrive here twice. Collapse it at this single choke point. // Return true (not false): a notification for the event IS being shown by the // first caller, so the settings "send test" success probe stays honest. - if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? ''}`)) { + if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? payload?.tag ?? ''}`)) { return true } diff --git a/apps/desktop/src/contrib/plugin.test.ts b/apps/desktop/src/contrib/plugin.test.ts index ca32f49e89..95bb250458 100644 --- a/apps/desktop/src/contrib/plugin.test.ts +++ b/apps/desktop/src/contrib/plugin.test.ts @@ -1,7 +1,11 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, vi } from 'vitest' + +import { dispatchPluginNativeNotification } from '@/store/native-notifications' import { createPluginContext } from './plugin' +vi.mock('@/store/native-notifications', () => ({ dispatchPluginNativeNotification: vi.fn() })) + describe('createPluginContext.onDispose', () => { it('collects arbitrary cleanups so the host runs them on deactivate', () => { const disposers: Array<() => void> = [] @@ -19,3 +23,11 @@ describe('createPluginContext.onDispose', () => { expect(cleaned).toBe(true) }) }) + +describe('createPluginContext.notifyNative', () => { + it('dispatches a native notification attributed to the plugin', () => { + const ctx = createPluginContext('demo') + ctx.notifyNative({ body: 'b', title: 't' }) + expect(dispatchPluginNativeNotification).toHaveBeenCalledWith('demo', { body: 'b', title: 't' }) + }) +}) diff --git a/apps/desktop/src/contrib/plugin.ts b/apps/desktop/src/contrib/plugin.ts index a88aa07882..0f9bc849cb 100644 --- a/apps/desktop/src/contrib/plugin.ts +++ b/apps/desktop/src/contrib/plugin.ts @@ -15,11 +15,13 @@ import { pluginRest, type PluginRestOptions, pluginSocket } from '@/hermes' import { createPluginI18n, type PluginI18n } from '@/i18n' import { readKey, writeKey } from '@/lib/storage' +import { dispatchPluginNativeNotification, type PluginNativeNotificationInput } from '@/store/native-notifications' import { registry } from './registry' import type { Contribution } from './types' export type { PluginRestOptions } from '@/hermes' +export type { PluginNativeNotificationInput } from '@/store/native-notifications' /** A contribution as a plugin author writes it — provenance + id scoping are * the host's job, so those fields are off-limits here. */ @@ -54,6 +56,10 @@ export interface PluginContext { * returned. Resolves to a no-op on OAuth remotes — treat it as an * accelerator over your polling, never a replacement. */ socket: (path: string, onMessage: (data: unknown) => void) => () => void + /** Native OS notification (Electron), attributed to this plugin. Gated by + * Settings ▸ Notifications ▸ "Plugin notifications" and fires only while + * the user is away from Hermes — use `host.notify` for the in-app toast. */ + notifyNative: (input: PluginNativeNotificationInput) => void /** Plugin-scoped persistence. */ storage: PluginStorage /** Plugin-scoped i18n: ship + register locale bundles under this plugin, @@ -115,6 +121,7 @@ export function createPluginContext(pluginId: string, onDispose?: (dispose: () = onDispose: fn => void track(fn), rest: (path: string, opts?: PluginRestOptions) => pluginRest(pluginId, path, opts), socket: (path, onMessage) => track(pluginSocket(pluginId, path, onMessage)), + notifyNative: input => dispatchPluginNativeNotification(pluginId, input), storage: createPluginStorage(pluginId), i18n: createPluginI18n(pluginId, track) } diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index c2b677619e..548badf5e6 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -769,6 +769,8 @@ export interface HermesNotification { silent?: boolean kind?: string sessionId?: string + /** Dedupe discriminator for session-less notifications (e.g. plugin id). */ + tag?: string actions?: { id: string; text: string }[] } diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index fe764eb310..2ec60d2859 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -395,6 +395,10 @@ export const en: Translations = { credits: { label: 'Credit alerts', description: 'Credit access is paused or restored.' + }, + plugin: { + label: 'Plugin notifications', + description: 'A desktop plugin sent a notification while Hermes was in the background.' } }, test: 'Send test notification', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 16d58834c6..d1913d24d8 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -269,6 +269,10 @@ export const ja = defineLocale({ credits: { label: 'クレジット通知', description: 'クレジットの利用が停止または復旧しました。' + }, + plugin: { + label: 'プラグイン通知', + description: 'Hermes がバックグラウンドの間に、デスクトッププラグインが通知を送信しました。' } }, test: 'テスト通知を送信', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index b82feb4001..e2a49f4234 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -324,7 +324,7 @@ export interface Translations { enableAllDesc: string focusedHint: string kinds: Record< - 'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError', + 'approval' | 'backgroundDone' | 'credits' | 'input' | 'plugin' | 'turnDone' | 'turnError', { label: string; description: string } > test: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 31a0b62a53..bb71f3d094 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -263,6 +263,10 @@ export const zhHant = defineLocale({ credits: { label: '額度提醒', description: '額度存取被暫停或恢復。' + }, + plugin: { + label: '外掛通知', + description: 'Hermes 在背景時,桌面外掛傳送了通知。' } }, test: '傳送測試通知', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 10b4727245..79a9a1c143 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -387,6 +387,10 @@ export const zh: Translations = { credits: { label: '额度提醒', description: '额度访问被暂停或恢复。' + }, + plugin: { + label: '插件通知', + description: 'Hermes 在后台时,桌面插件发送了通知。' } }, test: '发送测试通知', diff --git a/apps/desktop/src/sdk/index.ts b/apps/desktop/src/sdk/index.ts index 1c1b696d37..60fa6f48de 100644 --- a/apps/desktop/src/sdk/index.ts +++ b/apps/desktop/src/sdk/index.ts @@ -200,6 +200,7 @@ export type { HermesPlugin, PluginContext, PluginContribution, + PluginNativeNotificationInput, PluginRestOptions, PluginStorage } from '@/contrib/plugin' diff --git a/apps/desktop/src/store/native-notifications.test.ts b/apps/desktop/src/store/native-notifications.test.ts index 4ebfd95a88..8e4feccf51 100644 --- a/apps/desktop/src/store/native-notifications.test.ts +++ b/apps/desktop/src/store/native-notifications.test.ts @@ -3,6 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { $gateway } from './gateway' import { dispatchNativeNotification, + dispatchPluginNativeNotification, NATIVE_NOTIFICATION_KINDS, respondToApprovalAction, sendTestNativeNotification, @@ -170,6 +171,34 @@ describe('dispatchNativeNotification post-connect baseline', () => { }) }) +describe('dispatchPluginNativeNotification', () => { + it('fires while the user is away and tags the plugin id for dedupe', () => { + dispatchPluginNativeNotification('index-network', { body: 'New match', title: 'Opportunity' }) + expect(notify).toHaveBeenCalledWith( + expect.objectContaining({ body: 'New match', kind: 'plugin', tag: 'index-network', title: 'Opportunity' }) + ) + }) + + it('suppresses while the window is focused (the in-app toast covers foreground)', () => { + setWindowState({ focused: true, hidden: false }) + dispatchPluginNativeNotification('focused-plugin', { title: 'Opportunity' }) + expect(notify).not.toHaveBeenCalled() + }) + + it('is gated by the "plugin" kind preference', () => { + setNativeNotifyKind('plugin', false) + dispatchPluginNativeNotification('muted-plugin', { title: 'Opportunity' }) + expect(notify).not.toHaveBeenCalled() + }) + + it('throttles per plugin, so two plugins cannot collapse each other', () => { + dispatchPluginNativeNotification('plugin-a', { title: 'a' }) + dispatchPluginNativeNotification('plugin-a', { title: 'a again' }) + dispatchPluginNativeNotification('plugin-b', { title: 'b' }) + expect(notify).toHaveBeenCalledTimes(2) + }) +}) + describe('dispatchNativeNotification throttle', () => { it('collapses duplicate kind+session within the throttle window', () => { const sessionId = freshSession() diff --git a/apps/desktop/src/store/native-notifications.ts b/apps/desktop/src/store/native-notifications.ts index 450360b766..dba7bb58de 100644 --- a/apps/desktop/src/store/native-notifications.ts +++ b/apps/desktop/src/store/native-notifications.ts @@ -9,7 +9,14 @@ import { $activeSessionId } from './session' // Native OS notifications (Electron `Notification`), separate from the in-app // toast feed in `notifications.ts`. Each kind toggles independently. -export type NativeNotificationKind = 'approval' | 'backgroundDone' | 'credits' | 'input' | 'turnDone' | 'turnError' +export type NativeNotificationKind = + | 'approval' + | 'backgroundDone' + | 'credits' + | 'input' + | 'plugin' + | 'turnDone' + | 'turnError' export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [ 'approval', @@ -17,7 +24,8 @@ export const NATIVE_NOTIFICATION_KINDS: readonly NativeNotificationKind[] = [ 'turnDone', 'turnError', 'backgroundDone', - 'credits' + 'credits', + 'plugin' ] // Blocking prompts — surface even while focused if they're for another session. @@ -32,7 +40,15 @@ const STORAGE_KEY = 'hermes:native-notifications' const DEFAULT_PREFS: NativeNotificationPrefs = { enabled: true, - kinds: { approval: true, backgroundDone: true, credits: true, input: true, turnDone: true, turnError: true } + kinds: { + approval: true, + backgroundDone: true, + credits: true, + input: true, + plugin: true, + turnDone: true, + turnError: true + } } function readPrefs(): NativeNotificationPrefs { @@ -152,6 +168,12 @@ export interface NativeNotificationInput { global?: boolean silent?: boolean actions?: NativeNotificationAction[] + /** + * Extra throttle/dedupe discriminator for session-less notifications (e.g. + * the plugin id), so unrelated emitters of the same kind don't collapse + * into one another. Never drives click-to-focus like `sessionId` does. + */ + tag?: string } export function dispatchNativeNotification(input: NativeNotificationInput): void { @@ -169,7 +191,7 @@ export function dispatchNativeNotification(input: NativeNotificationInput): void return } - if (throttled(`${input.kind}:${input.sessionId ?? (input.global ? 'global' : '')}`, Date.now())) { + if (throttled(`${input.kind}:${input.sessionId ?? input.tag ?? (input.global ? 'global' : '')}`, Date.now())) { return } @@ -179,10 +201,28 @@ export function dispatchNativeNotification(input: NativeNotificationInput): void kind: input.kind, sessionId: input.sessionId ?? undefined, silent: input.silent, + tag: input.tag, title: input.title }) } +// -- the plugin door (`ctx.notifyNative`) ------------------------------------- + +export interface PluginNativeNotificationInput { + title: string + body?: string + silent?: boolean +} + +/** Native OS notification on behalf of a plugin. One "Plugin notifications" + * preference gates all plugins; the plugin id keys throttling/dedupe so two + * plugins can't collapse each other's notifications. Fires only while the + * user is away from Hermes — the in-app toast (`host.notify`) covers the + * foreground case. */ +export function dispatchPluginNativeNotification(pluginId: string, input: PluginNativeNotificationInput): void { + dispatchNativeNotification({ ...input, global: true, kind: 'plugin', tag: pluginId }) +} + // Resolve a pending approval from a notification button, mirroring the in-app // Run/Reject bar. Keyed by session id — a background approval has no local guard. export async function respondToApprovalAction(sessionId: null | string, actionId: string): Promise { diff --git a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md index 3d7d5f2a92..6e79fefaf6 100644 --- a/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md +++ b/skills/autonomous-ai-agents/hermes-agent/references/desktop-plugins.md @@ -71,6 +71,11 @@ The ONLY import surface is `@hermes/plugin-sdk` (plus `react` / (renders below Artifacts, lights up at the route) — and/or a `PALETTE_AREA` command calling `host.navigate('/my-page')`. - `ctx.storage.get/set/remove` — persistence namespaced to your plugin. +- `ctx.notifyNative({ title, body?, silent? })` — native OS notification + attributed to your plugin. Fires only while the user is away from Hermes + (use `host.notify` for the in-app toast); gated by Settings ▸ Notifications ▸ + "Plugin notifications" and throttled per plugin — reserve it for genuinely + notable events. - `ctx.i18n.register({ en, ja, ... })` — ship your OWN locale bundles, scoped to your plugin (never edit core `en.ts`). Values are literal strings or interpolator functions; nested trees are addressed by dot-path. Read them diff --git a/website/docs/developer-guide/desktop-plugin-sdk.md b/website/docs/developer-guide/desktop-plugin-sdk.md index efc4fb3e83..f77a647391 100644 --- a/website/docs/developer-guide/desktop-plugin-sdk.md +++ b/website/docs/developer-guide/desktop-plugin-sdk.md @@ -168,6 +168,8 @@ interface PluginContext { rest: (path: string, opts?: PluginRestOptions) => Promise /** Live WebSocket to this plugin's own namespace. Returns a disposer. */ socket: (path: string, onMessage: (data: unknown) => void) => () => void + /** Native OS notification (Electron), attributed to this plugin. */ + notifyNative: (input: { title: string; body?: string; silent?: boolean }) => void /** Plugin-scoped JSON persistence (keys live under `hermes.plugin..`). */ storage: PluginStorage } @@ -370,6 +372,7 @@ host.state.viewport // ReadableAtom<{ width, height, narrow }> host.notify({ kind, message, title?, detail?, action? }) // toast; returns id host.notifyError(error, fallbackMessage) // toast an error +ctx.notifyNative({ title, body?, silent? }) // native OS notification host.navigate('/route') // hash-route navigation host.onEvent(type, fn) // gateway event stream ('*' = all); returns disposer host.logs(...) // tail an app log file @@ -385,6 +388,14 @@ listener can't affect app dispatch. Every `host` door is async-safe: a sync thro from an internal helper (e.g. no desktop bridge in a plain browser) becomes a rejection your `.catch()` sees, never an error-boundary crash. +`ctx.notifyNative` (on the plugin context, so the notification is attributed to +your plugin) posts a **native OS notification** — the same Electron pipeline the +app's own approval/turn alerts use. It fires only while the user is away from +Hermes (backgrounded / unfocused); use `host.notify` for the in-app toast when +they're looking at the app. Users can silence it per device under Settings ▸ +Notifications ▸ "Plugin notifications", and repeats from the same plugin are +throttled, so treat it as a signal for genuinely notable events — not a log. + ## Data layer — React Query + nanostores Plugins share the app's single `QueryClient`, so plugin queries cache, dedupe, @@ -597,7 +608,7 @@ not treat this pipeline as a trust boundary. | Category | Exports | |----------|---------| | Host | `host` (`.state.*`, `.notify`, `.notifyError`, `.navigate`, `.onEvent`, `.logs`, `.status`, `.restartGateway`, `.request`) | -| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginRestOptions`, `Contribution` | +| Plugin contract | `HermesPlugin`, `PluginContext`, `PluginContribution`, `PluginStorage`, `PluginRestOptions`, `PluginNativeNotificationInput`, `Contribution` | | Area constants | `PANES_AREA`, `ROUTES_AREA`, `SIDEBAR_NAV_AREA`, `STATUSBAR_AREAS`, `TITLEBAR_AREAS`, `PALETTE_AREA`, `KEYBINDS_AREA`, `THEMES_AREA`, `COMPOSER_AREAS` | | Area payloads | `RouteContribution`, `SidebarNavContribution`, `StatusbarItem`, `TitlebarTool`, `PaletteContribution`, `KeybindContribution`, `ComposerMiddleware`, `ComposerAttachmentProvider` | | React / state | `useValue`, `atom`, `computed`, `useQuery`, `useMutation`, `useQueryClient`, `queryClient`, `Contribute` | From 1ed702be73e40c437f151b6fb7fba6191a3e6d13 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Tue, 4 Aug 2026 11:31:51 -0600 Subject: [PATCH 03/28] refactor(desktop): share render weight between the two transcript budgets MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit messageRenderWeight moves out of thread/list.tsx into lib/render-weight.ts. The DOM page budget already spends render cost rather than message count — the store window added next needs the same currency, and one weight function keeps the two layers from drifting apart. No behavior change. --- .../assistant-ui/thread/list.test.ts | 4 +- .../components/assistant-ui/thread/list.tsx | 97 +++++-------------- apps/desktop/src/lib/render-weight.ts | 84 ++++++++++++++++ 3 files changed, 112 insertions(+), 73 deletions(-) create mode 100644 apps/desktop/src/lib/render-weight.ts diff --git a/apps/desktop/src/components/assistant-ui/thread/list.test.ts b/apps/desktop/src/components/assistant-ui/thread/list.test.ts index c0cd1da558..a44314d154 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.test.ts +++ b/apps/desktop/src/components/assistant-ui/thread/list.test.ts @@ -1,5 +1,7 @@ import { describe, expect, it } from 'vitest' +import { messageRenderWeight, RENDER_WEIGHT_CHARS } from '@/lib/render-weight' + import { buildGroups, firstVisibleGroupIndex, @@ -7,8 +9,6 @@ import { LIVE_TAIL_PARTS, liveTailStart, type MessageGroup, - messageRenderWeight, - RENDER_WEIGHT_CHARS, resolveThreadScrollTarget } from './list' diff --git a/apps/desktop/src/components/assistant-ui/thread/list.tsx b/apps/desktop/src/components/assistant-ui/thread/list.tsx index d8fd0f0a5c..304b3eb3ec 100644 --- a/apps/desktop/src/components/assistant-ui/thread/list.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/list.tsx @@ -16,6 +16,7 @@ import { import { type GetTargetScrollTop, useStickToBottom } from 'use-stick-to-bottom' import { useI18n } from '@/i18n' +import { messageRenderWeight } from '@/lib/render-weight' import { cn } from '@/lib/utils' import { onScrollToBottomRequest, @@ -28,6 +29,8 @@ import { isSecondaryWindow } from '@/store/windows' import { MessageRenderBoundary } from '../message-render-boundary' +import { resolveShowEarlierAction, useTranscriptWindow } from './transcript-window' + type ThreadMessageComponents = ComponentProps['components'] export type MessageGroup = { id: string; weight: number } & ( @@ -47,8 +50,6 @@ export type MessageGroup = { id: string; weight: number } & ( // a virtualizer — pure rendering, never touches scrollTop, so it can't fight // use-stick-to-bottom (the single scroll owner). const RENDER_BUDGET = 300 -export const RENDER_WEIGHT_CHARS = 512 -const MAX_MEASURED_MESSAGE_CHARS = RENDER_BUDGET * RENDER_WEIGHT_CHARS // On session switch, paint a small budget first (enough for the bottom turn(s) // the user actually sees after scroll-to-bottom), then bump to the full budget // in a requestAnimationFrame — defers the heavy markdown+syntax-highlight render @@ -76,70 +77,6 @@ export const resolveThreadScrollTarget: GetTargetScrollTop = (targetScrollTop, { return remaining >= 0 && remaining <= SCROLL_TARGET_EPSILON_PX ? currentScrollTop : targetScrollTop } -const contentWeightCache = new WeakMap() -const NON_RENDERED_CONTENT_FIELDS = new Set(['id', 'role', 'toolCallId', 'toolName', 'type']) - -/** - * Estimate the synchronous renderer cost of one assistant-ui message. - * - * The traversal is capped once a single message has enough text to consume a - * complete render page. Going further cannot affect which whole turn crosses - * the budget, and avoiding an unbounded walk matters for deeply nested tool - * payloads. A WeakMap keeps settled history O(message count) on later store - * updates; assistant-ui publishes a new content array when a streaming message - * changes, so the live tail still receives a fresh weight. - */ -export function messageRenderWeight(content: unknown): number { - if (!Array.isArray(content)) { - return 1 - } - - const cached = contentWeightCache.get(content) - - if (cached !== undefined) { - return cached - } - - const seen = new WeakSet() - const pending: unknown[] = [...content] - let characters = 0 - - while (pending.length > 0 && characters < MAX_MEASURED_MESSAGE_CHARS) { - const value = pending.pop() - - if (typeof value === 'string') { - characters += Math.min(value.length, MAX_MEASURED_MESSAGE_CHARS - characters) - - continue - } - - if (!value || typeof value !== 'object' || seen.has(value)) { - continue - } - - seen.add(value) - - if (Array.isArray(value)) { - for (const nested of value) { - pending.push(nested) - } - - continue - } - - for (const [key, nested] of Object.entries(value)) { - if (!NON_RENDERED_CONTENT_FIELDS.has(key)) { - pending.push(nested) - } - } - } - - const weight = Math.max(1, content.length) + Math.ceil(characters / RENDER_WEIGHT_CHARS) - contentWeightCache.set(content, weight) - - return weight -} - interface ThreadMessageListProps { clampToComposer: boolean components: ThreadMessageComponents @@ -315,6 +252,8 @@ const ThreadMessageListInner: FC = ({ targetScrollTop: resolveThreadScrollTarget }) + const { olderAvailable, expandWindow } = useTranscriptWindow() + const [renderBudget, setRenderBudget] = useState(FIRST_PAINT_BUDGET) // Cut the budget during RENDER, not in the post-commit layout effect. An @@ -540,11 +479,26 @@ const ThreadMessageListInner: FC = ({ // Prepend an older page while preserving the on-screen position. The user is // scrolled up (reading history) so the stick-to-bottom lock is escaped and - // won't fight this manual restore. + // won't fight this manual restore. Spend the already-materialized DOM page + // first; only when that is exhausted pull more messages out of the session + // store (#55191). const showEarlier = useCallback(() => { + const action = resolveShowEarlierAction(hiddenCount, olderAvailable) + + if (!action) { + return + } + anchorBeforePrepend() - setRenderBudget(budget => budget + RENDER_BUDGET) - }, [anchorBeforePrepend]) + + if (action === 'dom') { + setRenderBudget(budget => budget + RENDER_BUDGET) + + return + } + + expandWindow() + }, [anchorBeforePrepend, expandWindow, hiddenCount, olderAvailable]) useLayoutEffect(() => { const el = scrollRef.current @@ -553,7 +507,8 @@ const ThreadMessageListInner: FC = ({ el.scrollTop = el.scrollHeight - restoreFromBottomRef.current restoreFromBottomRef.current = null } - }, [scrollRef, renderBudget]) + // renderBudget covers DOM pages; groups.length covers store-window expands. + }, [scrollRef, renderBudget, groups.length]) // The row array is memoized on the inputs the rows actually read. This // component re-renders on every isAtBottom flip — and use-stick-to-bottom @@ -647,7 +602,7 @@ const ThreadMessageListInner: FC = ({ data-slot="aui_thread-content" ref={contentRef as React.RefCallback} > - {hiddenCount > 0 && ( + {(hiddenCount > 0 || olderAvailable) && (