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:
Brooklyn Nicholson 2026-07-29 21:26:22 -05:00
parent 33bbf24a62
commit 789d69271a
5 changed files with 146 additions and 6 deletions

View file

@ -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([])
})
})

View file

@ -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.
*

View file

@ -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

View file

@ -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,

View file

@ -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