diff --git a/apps/desktop/src/app/chat/composer/focus.test.ts b/apps/desktop/src/app/chat/composer/focus.test.ts index bc92932bb8f..4fd21087336 100644 --- a/apps/desktop/src/app/chat/composer/focus.test.ts +++ b/apps/desktop/src/app/chat/composer/focus.test.ts @@ -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 => { + 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([]) + }) +}) diff --git a/apps/desktop/src/app/chat/composer/focus.ts b/apps/desktop/src/app/chat/composer/focus.ts index 3302d031078..2d403edef6e 100644 --- a/apps/desktop/src/app/chat/composer/focus.ts +++ b/apps/desktop/src/app/chat/composer/focus.ts @@ -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('[data-composer-target]').find( + el => el.closest('[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. * diff --git a/apps/desktop/src/app/chat/composer/model-pill.tsx b/apps/desktop/src/app/chat/composer/model-pill.tsx index 2a26db4094c..26fbe0266af 100644 --- a/apps/desktop/src/app/chat/composer/model-pill.tsx +++ b/apps/desktop/src/app/chat/composer/model-pill.tsx @@ -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 diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index e1f73b03344..07ac0384e63 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -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, diff --git a/apps/desktop/src/lib/keybinds/actions.ts b/apps/desktop/src/lib/keybinds/actions.ts index 799d2d282c3..f4a609c202f 100644 --- a/apps/desktop/src/lib/keybinds/actions.ts +++ b/apps/desktop/src/lib/keybinds/actions.ts @@ -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