mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): ⌘⇧M opens the model menu on the pane under the pointer
composer.modelPicker shipped unbound and opened the full-screen picker dialog. It now defaults to ⌘⇧M — the chord LibreChat, Open WebUI, and Cherry Studio independently converged on — and toggles the composer pill's live dropdown instead, search field ready: ⌘⇧M → 'gr' → Enter switches model in two keypresses. Routing follows the tab-verb convention (#74447): the request lands on the chat surface in the hovered zone first, then the active composer, skipping hidden keep-alive tabs. With no chat surface on screen (settings, profiles) the keybind falls back to the full dialog; with the gateway closed the pill opens the dialog like a click would.
This commit is contained in:
parent
33bbf24a62
commit
789d69271a
5 changed files with 146 additions and 6 deletions
|
|
@ -1,12 +1,16 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { $hoveredTreeGroup } from '@/components/pane-shell/tree/store'
|
||||
|
||||
import {
|
||||
blurComposerInput,
|
||||
getActiveComposer,
|
||||
markActiveComposer,
|
||||
onComposerFocusRequest,
|
||||
onComposerModelMenuRequest,
|
||||
releaseActiveComposer,
|
||||
requestComposerFocus
|
||||
requestComposerFocus,
|
||||
requestModelMenuToggle
|
||||
} from './focus'
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
|
||||
|
|
@ -45,6 +49,7 @@ afterEach(() => {
|
|||
// `activeTarget` is module-level — a case that leaves a stale claim behind
|
||||
// would otherwise decide the next one.
|
||||
markActiveComposer('main')
|
||||
$hoveredTreeGroup.set(null)
|
||||
})
|
||||
|
||||
describe('blurComposerInput', () => {
|
||||
|
|
@ -216,3 +221,65 @@ describe('resolveActive / keep-alive tab heal', () => {
|
|||
expect(getActiveComposer()).toBe('edit')
|
||||
})
|
||||
})
|
||||
|
||||
/** A chat surface inside a layout zone, mirroring ChatView-in-tree-group. */
|
||||
function mountZonedSurface(target: string, zone: string, hidden = false) {
|
||||
const group = document.createElement('div')
|
||||
group.dataset.treeGroup = zone
|
||||
const layer = document.createElement('div')
|
||||
layer.toggleAttribute('data-pane-hidden', hidden)
|
||||
const surface = document.createElement('div')
|
||||
surface.dataset.composerTarget = target
|
||||
layer.append(surface)
|
||||
group.append(layer)
|
||||
document.body.append(group)
|
||||
|
||||
return surface
|
||||
}
|
||||
|
||||
const collectModelMenuTargets = async (): Promise<string[]> => {
|
||||
const saw: string[] = []
|
||||
const off = onComposerModelMenuRequest(target => saw.push(target))
|
||||
|
||||
await new Promise(resolve => window.setTimeout(resolve, 0))
|
||||
off()
|
||||
|
||||
return saw
|
||||
}
|
||||
|
||||
describe('requestModelMenuToggle', () => {
|
||||
it('targets the pane under the pointer over the focused one (#74447 convention)', async () => {
|
||||
mountZonedSurface('main', 'zone-a')
|
||||
mountZonedSurface('tile:hovered', 'zone-b')
|
||||
markActiveComposer('main')
|
||||
$hoveredTreeGroup.set('zone-b')
|
||||
|
||||
expect(requestModelMenuToggle()).toBe(true)
|
||||
expect(await collectModelMenuTargets()).toEqual(['tile:hovered'])
|
||||
})
|
||||
|
||||
it('falls back to the active composer when the pointer is off every zone', async () => {
|
||||
mountZonedSurface('main', 'zone-a')
|
||||
mountZonedSurface('tile:other', 'zone-b')
|
||||
markActiveComposer('tile:other')
|
||||
|
||||
expect(requestModelMenuToggle()).toBe(true)
|
||||
expect(await collectModelMenuTargets()).toEqual(['tile:other'])
|
||||
})
|
||||
|
||||
it('skips a hidden keep-alive tab in the hovered zone (targets its visible sibling)', async () => {
|
||||
mountZonedSurface('main', 'zone-a', true)
|
||||
mountZonedSurface('tile:front', 'zone-a')
|
||||
markActiveComposer('main')
|
||||
$hoveredTreeGroup.set('zone-a')
|
||||
|
||||
expect(requestModelMenuToggle()).toBe(true)
|
||||
expect(await collectModelMenuTargets()).toEqual(['tile:front'])
|
||||
})
|
||||
|
||||
it('returns false with no chat surface on screen so the caller can open the dialog', async () => {
|
||||
// Settings/profiles routes: no [data-composer-target] anywhere.
|
||||
expect(requestModelMenuToggle()).toBe(false)
|
||||
expect(await collectModelMenuTargets()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@
|
|||
* steal focus from the composer effect.
|
||||
*/
|
||||
|
||||
import { queryVisible } from '@/components/pane-shell/pane-visibility'
|
||||
import { queryAllVisible, queryVisible } from '@/components/pane-shell/pane-visibility'
|
||||
import { $hoveredTreeGroup } from '@/components/pane-shell/tree/store'
|
||||
|
||||
import type { InlineRefInput } from './inline-refs'
|
||||
import { RICH_INPUT_SLOT } from './rich-editor'
|
||||
|
|
@ -42,6 +43,7 @@ const INSERT_EVENT = 'hermes:composer-insert'
|
|||
const INSERT_REFS_EVENT = 'hermes:composer-insert-refs'
|
||||
const SUBMIT_EVENT = 'hermes:composer-submit'
|
||||
const VOICE_TOGGLE_EVENT = 'hermes:composer-voice-toggle'
|
||||
const MODEL_MENU_EVENT = 'hermes:composer-model-menu'
|
||||
|
||||
/** Inline edit composer root — mounted only while a user bubble is being edited. */
|
||||
const EDIT_COMPOSER_ROOT = '[data-slot="aui_edit-composer-root"]'
|
||||
|
|
@ -258,6 +260,44 @@ export const requestVoiceToggle = (target: ComposerTarget | 'active' = 'active')
|
|||
export const onComposerVoiceToggleRequest = (handler: (target: ComposerTarget) => void) =>
|
||||
subscribe<{ target: ComposerTarget }>(VOICE_TOGGLE_EVENT, ({ target }) => handler(target))
|
||||
|
||||
/** The chat surface inside the zone the pointer is over, if any. Mirrors the
|
||||
* tab verbs' hover-first targeting (`tabTargetGroupId`, #74447): the model
|
||||
* hotkey lands in the pane you're pointing at without clicking into it first.
|
||||
* Hidden keep-alive tabs are skipped like every document-wide lookup. */
|
||||
const composerTargetInHoveredZone = (): ComposerTarget | null => {
|
||||
const zone = $hoveredTreeGroup.get()
|
||||
|
||||
if (!zone || typeof document === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
const surface = queryAllVisible<HTMLElement>('[data-composer-target]').find(
|
||||
el => el.closest<HTMLElement>('[data-tree-group]')?.dataset.treeGroup === zone
|
||||
)
|
||||
|
||||
return (surface?.dataset.composerTarget as ComposerTarget | undefined) ?? null
|
||||
}
|
||||
|
||||
/** Toggle ONE composer's model menu — the `composer.modelPicker` hotkey.
|
||||
* Targets the pane under the pointer first (the tab-verb convention), then
|
||||
* the active composer. Returns false when no chat surface is on screen at
|
||||
* all (settings, profiles…), so the caller can fall back to the full
|
||||
* model-picker dialog instead of dispatching into the void. */
|
||||
export const requestModelMenuToggle = (): boolean => {
|
||||
if (typeof document !== 'undefined' && !queryVisible('[data-composer-target]')) {
|
||||
return false
|
||||
}
|
||||
|
||||
dispatch<{ target: ComposerTarget }>(MODEL_MENU_EVENT, {
|
||||
target: composerTargetInHoveredZone() ?? resolveActive()
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
export const onComposerModelMenuRequest = (handler: (target: ComposerTarget) => void) =>
|
||||
subscribe<{ target: ComposerTarget }>(MODEL_MENU_EVENT, ({ target }) => handler(target))
|
||||
|
||||
/**
|
||||
* Focus a composer input across React commit + browser focus restore.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { useStore } from '@nanostores/react'
|
||||
import { useState } from 'react'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { useSessionView } from '@/app/chat/session-view'
|
||||
import { ModelMenuCloseContext } from '@/app/shell/model-menu-panel'
|
||||
|
|
@ -13,6 +13,8 @@ import { formatModelStatusLabel } from '@/lib/model-status-label'
|
|||
import { cn } from '@/lib/utils'
|
||||
import { $currentModelSource, $defaultReasoningEffort, setModelPickerOpen } from '@/store/session'
|
||||
|
||||
import { onComposerModelMenuRequest } from './focus'
|
||||
import { useComposerScope } from './scope'
|
||||
import type { ChatBarState } from './types'
|
||||
|
||||
const PILL = cn(
|
||||
|
|
@ -51,6 +53,28 @@ export function ModelPill({
|
|||
const defaultEffort = useStore($defaultReasoningEffort)
|
||||
const runtimeId = useStore(view.$runtimeId)
|
||||
const [open, setOpen] = useState(false)
|
||||
const scope = useComposerScope()
|
||||
const hasLiveMenu = Boolean(model.modelMenuContent)
|
||||
|
||||
// The `composer.modelPicker` hotkey, routed to exactly one surface (the pane
|
||||
// under the pointer, else the active composer — see requestModelMenuToggle).
|
||||
// Toggles the live dropdown; with no live menu (gateway closed) it opens the
|
||||
// full picker dialog, same as clicking the pill.
|
||||
useEffect(
|
||||
() =>
|
||||
onComposerModelMenuRequest(target => {
|
||||
if (target !== scope.target || disabled) {
|
||||
return
|
||||
}
|
||||
|
||||
if (hasLiveMenu) {
|
||||
setOpen(prev => !prev)
|
||||
} else {
|
||||
setModelPickerOpen(true)
|
||||
}
|
||||
}),
|
||||
[scope.target, disabled, hasLiveMenu]
|
||||
)
|
||||
|
||||
// The composer pick is sticky: a manual selection is pinned and every NEW
|
||||
// chat uses it instead of the Settings → Model default — silently, which has
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ import { toggleStatusbarVisible } from '@/store/statusbar-prefs'
|
|||
import { openNewWindow } from '@/store/windows'
|
||||
import { useTheme } from '@/themes/context'
|
||||
|
||||
import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus'
|
||||
import { requestComposerFocus, requestModelMenuToggle, requestVoiceToggle } from '../chat/composer/focus'
|
||||
import { openSession } from '../open-session'
|
||||
import {
|
||||
AGENTS_ROUTE,
|
||||
|
|
@ -134,7 +134,13 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
|
|||
'keybinds.openPanel': () => navigate(`${SETTINGS_ROUTE}?tab=keybinds`),
|
||||
|
||||
'composer.focus': () => requestComposerFocus('active'),
|
||||
'composer.modelPicker': () => setModelPickerOpen(true),
|
||||
// Toggle the composer pill's live model dropdown (pane under the pointer,
|
||||
// else active composer); no chat surface on screen → the full dialog.
|
||||
'composer.modelPicker': () => {
|
||||
if (!requestModelMenuToggle()) {
|
||||
setModelPickerOpen(true)
|
||||
}
|
||||
},
|
||||
'composer.voice': requestVoiceToggle,
|
||||
|
||||
'nav.commandPalette': toggleCommandPalette,
|
||||
|
|
|
|||
|
|
@ -56,7 +56,10 @@ export const KEYBIND_ACTIONS: readonly KeybindActionMeta[] = [
|
|||
// ── Composer ─────────────────────────────────────────────────────────────
|
||||
// Soft `/` / Enter focus (gated); other printables type-to-focus unbound.
|
||||
{ id: 'composer.focus', category: 'composer', defaults: ['/', 'enter'] },
|
||||
{ id: 'composer.modelPicker', category: 'composer', defaults: [] },
|
||||
// ⌘⇧M — "m" for model; the convention chat apps converged on (LibreChat,
|
||||
// Open WebUI, and Cherry Studio all ship the same chord). Opens the pill's
|
||||
// live dropdown on the pane under the pointer, else the active composer.
|
||||
{ id: 'composer.modelPicker', category: 'composer', defaults: ['mod+shift+m'] },
|
||||
// Voice conversation toggle. Matches the documented `voice.record_key`
|
||||
// (Ctrl+B). On macOS that's literally ⌃B — distinct from the ⌘B sidebar
|
||||
// toggle. Off macOS `ctrl` folds to `mod`, which IS the ⌘B/Ctrl+B sidebar
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue