mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
feat(desktop): tab verbs follow the pane under the pointer
⌘1…⌘9, ⌃Tab, and the ⌘W / ⌘T family all resolved the last-interacted zone, so switching tabs in a second pane meant clicking into it first. They now resolve the HOVERED zone when the pointer is in one, falling back to the focused zone otherwise — hover pane 1, ⌘2; hover pane 2, ⌘2, and each lands in its own strip. tabTargetGroupId() is the single resolver, keeping the number keys and the tab verbs from disagreeing about which zone is "the" zone the way they already couldn't. pointerover fires per boundary crossing rather than per mouse move, and leaving the document clears the override so a parked pointer never strands the keys on a stale zone.
This commit is contained in:
parent
7002ed9f9d
commit
15a55cbff1
2 changed files with 153 additions and 22 deletions
|
|
@ -0,0 +1,90 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// The tab verbs (⌘1…⌘9, ⌃Tab, ⌘W / ⌘T) target the zone under the POINTER when
|
||||
// there is one, so hovering a pane and hitting ⌘2 lands there without clicking
|
||||
// in first. Pointer off the panes falls back to the last interacted zone.
|
||||
|
||||
describe('hovered zone retargets the tab verbs', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
})
|
||||
|
||||
async function setup() {
|
||||
const tree = await import('@/components/pane-shell/tree/store')
|
||||
const model = await import('@/components/pane-shell/tree/model')
|
||||
const { registry } = await import('@/contrib/registry')
|
||||
|
||||
for (const id of ['workspace', 'session-tile:a', 'session-tile:b', 'session-tile:c']) {
|
||||
registry.register({
|
||||
area: 'panes',
|
||||
data: id === 'workspace' ? { placement: 'main', uncloseable: true } : { placement: 'main' },
|
||||
id,
|
||||
render: () => null,
|
||||
title: id
|
||||
})
|
||||
}
|
||||
|
||||
// Two chat zones side by side, each a real strip (≥2 shown tabs).
|
||||
tree.declareDefaultTree(
|
||||
model.split('row', [
|
||||
model.group(['workspace', 'session-tile:a'], { active: 'workspace', id: 'grp-main' }),
|
||||
model.group(['session-tile:b', 'session-tile:c'], { active: 'session-tile:b', id: 'grp-side' })
|
||||
])
|
||||
)
|
||||
|
||||
const activeOf = (id: string) => model.findGroup(tree.$layoutTree.get()!, id)?.active ?? null
|
||||
|
||||
return { activeOf, model, tree }
|
||||
}
|
||||
|
||||
it('⌘2 switches the HOVERED zone even while the other one holds focus', async () => {
|
||||
const { activeOf, tree } = await setup()
|
||||
|
||||
tree.noteActiveTreeGroup('grp-main')
|
||||
tree.noteHoveredTreeGroup('grp-side')
|
||||
|
||||
expect(tree.activateTreeTabSlot(2)).toBe(true)
|
||||
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(activeOf('grp-main')).toBe('session-tile:a')
|
||||
})
|
||||
|
||||
it('pointer off every zone falls back to the focused one', async () => {
|
||||
const { activeOf, tree } = await setup()
|
||||
|
||||
tree.noteActiveTreeGroup('grp-side')
|
||||
tree.noteHoveredTreeGroup('grp-main')
|
||||
tree.noteHoveredTreeGroup(null)
|
||||
|
||||
expect(tree.activateTreeTabSlot(2)).toBe(true)
|
||||
expect(activeOf('grp-side')).toBe('session-tile:c')
|
||||
expect(activeOf('grp-main')).toBe('workspace')
|
||||
})
|
||||
|
||||
it('⌃Tab and the ⌘T / ⌘W family follow the same hovered zone', async () => {
|
||||
const { activeOf, model, tree } = await setup()
|
||||
|
||||
tree.noteActiveTreeGroup('grp-main')
|
||||
tree.noteHoveredTreeGroup('grp-side')
|
||||
|
||||
expect(tree.cycleTreeTabInFocusedZone(1)).toBe(true)
|
||||
expect(activeOf('grp-side')).toBe('session-tile:c')
|
||||
expect(activeOf('grp-main')).toBe('workspace')
|
||||
|
||||
expect(tree.focusedSessionTabAnchor()).toBe('session-tile:c')
|
||||
|
||||
expect(tree.closeFocusedSessionTab()).toBe(true)
|
||||
expect(model.allPaneIds(tree.$layoutTree.get()!)).not.toContain('session-tile:c')
|
||||
expect(model.allPaneIds(tree.$layoutTree.get()!)).toContain('workspace')
|
||||
})
|
||||
})
|
||||
|
|
@ -256,25 +256,65 @@ export function noteActiveTreeGroup(groupId: null | string) {
|
|||
}
|
||||
}
|
||||
|
||||
/** Install the active-zone tracker (call once from the tree root). Records the
|
||||
/** The zone the pointer is currently over, or null off every zone. Transient —
|
||||
* it only OVERRIDES the focused zone while the mouse actually sits in one, so
|
||||
* moving the pointer away reverts the tab verbs to real focus rather than
|
||||
* stranding them on whatever the mouse last brushed past. */
|
||||
export const $hoveredTreeGroup = atom<null | string>(null)
|
||||
|
||||
/** Record the hovered zone (pointerover / pointer leaving the window). Idempotent. */
|
||||
export function noteHoveredTreeGroup(groupId: null | string) {
|
||||
if (groupId !== $hoveredTreeGroup.get()) {
|
||||
$hoveredTreeGroup.set(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
/** The zone every keyboard tab verb acts on: the HOVERED zone, else the focused
|
||||
* one. Hover-first is what makes ⌘1…⌘9 land in the pane you're pointing at
|
||||
* without clicking into it first; with the pointer outside the panes it's the
|
||||
* plain focused-zone behavior. One resolver so the number keys, ⌃Tab, and the
|
||||
* ⌘W / ⌘T family can never disagree about which zone is "the" zone. */
|
||||
function tabTargetGroupId(): null | string {
|
||||
return $hoveredTreeGroup.get() ?? $activeTreeGroup.get()
|
||||
}
|
||||
|
||||
const treeGroupOfEvent = (event: Event): null | string => {
|
||||
const el = event.target instanceof HTMLElement ? event.target : null
|
||||
|
||||
return el?.closest<HTMLElement>('[data-tree-group]')?.dataset.treeGroup ?? null
|
||||
}
|
||||
|
||||
/** Install the zone trackers (call once from the tree root). Records the
|
||||
* `[data-tree-group]` under each pointerdown / focusin so ⌘W knows which
|
||||
* zone's tab to close even when nothing is DOM-focused. */
|
||||
* zone's tab to close even when nothing is DOM-focused, and the one under the
|
||||
* pointer so the tab verbs follow the mouse. */
|
||||
export function trackActiveTreeGroup(): () => void {
|
||||
const track = (event: Event) => {
|
||||
const el = event.target instanceof HTMLElement ? event.target : null
|
||||
const groupId = el?.closest<HTMLElement>('[data-tree-group]')?.dataset.treeGroup
|
||||
const trackActive = (event: Event) => {
|
||||
const groupId = treeGroupOfEvent(event)
|
||||
|
||||
if (groupId) {
|
||||
noteActiveTreeGroup(groupId)
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener('pointerdown', track, true)
|
||||
window.addEventListener('focusin', track, true)
|
||||
// `pointerover` fires on every element boundary crossing (not every mouse
|
||||
// move), so leaving the panes for the titlebar reports null and the override
|
||||
// lifts on its own.
|
||||
const trackHover = (event: Event) => noteHoveredTreeGroup(treeGroupOfEvent(event))
|
||||
const clearHover = () => noteHoveredTreeGroup(null)
|
||||
|
||||
window.addEventListener('pointerdown', trackActive, true)
|
||||
window.addEventListener('focusin', trackActive, true)
|
||||
window.addEventListener('pointerover', trackHover, true)
|
||||
document.documentElement.addEventListener('pointerleave', clearHover)
|
||||
window.addEventListener('blur', clearHover)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('pointerdown', track, true)
|
||||
window.removeEventListener('focusin', track, true)
|
||||
window.removeEventListener('pointerdown', trackActive, true)
|
||||
window.removeEventListener('focusin', trackActive, true)
|
||||
window.removeEventListener('pointerover', trackHover, true)
|
||||
document.documentElement.removeEventListener('pointerleave', clearHover)
|
||||
window.removeEventListener('blur', clearHover)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -288,11 +328,12 @@ export const isSessionStripPane = (paneId: string): boolean =>
|
|||
paneId === 'workspace' || paneId.startsWith('session-tile:')
|
||||
|
||||
/** The zone the session-tab verbs (⌘W / ⌘T / ⌘⇧T / the strip's "+") act on:
|
||||
* the FOCUSED zone when it hosts a chat strip, else the workspace's zone.
|
||||
* Same source ⌘1…⌘9 indexes ($activeTreeGroup), so the number keys and the
|
||||
* tab verbs can't disagree about which strip is "the" strip. Focus parked in
|
||||
* the sidebar / terminal / files must NOT retarget them — those zones fall
|
||||
* back to main rather than letting ⌘W close the file tree. */
|
||||
* the TARGET zone (hovered, else focused) when it hosts a chat strip, else the
|
||||
* workspace's zone. Same source ⌘1…⌘9 indexes (`tabTargetGroupId`), so the
|
||||
* number keys and the tab verbs can't disagree about which strip is "the"
|
||||
* strip. A target parked in the sidebar / terminal / files must NOT retarget
|
||||
* them — those zones fall back to main rather than letting ⌘W close the file
|
||||
* tree. */
|
||||
function focusedSessionGroup(): GroupNode | null {
|
||||
const tree = $layoutTree.get()
|
||||
|
||||
|
|
@ -300,7 +341,7 @@ function focusedSessionGroup(): GroupNode | null {
|
|||
return null
|
||||
}
|
||||
|
||||
const groupId = $activeTreeGroup.get()
|
||||
const groupId = tabTargetGroupId()
|
||||
const focused = groupId ? findGroup(tree, groupId) : null
|
||||
|
||||
return focused?.panes.some(isSessionStripPane) ? focused : findGroupOfPane(tree, 'workspace')
|
||||
|
|
@ -426,12 +467,12 @@ function shownPanesInGroup(group: { panes: readonly string[] }): string[] {
|
|||
})
|
||||
}
|
||||
|
||||
/** ⌘1…⌘9: activate the Nth *visible* tab of the FOCUSED zone (the interaction
|
||||
* tracker's group), but only when it's a real tab strip (≥2 shown panes).
|
||||
/** ⌘1…⌘9: activate the Nth *visible* tab of the target zone (hovered, else
|
||||
* focused), but only when it's a real tab strip (≥2 shown panes).
|
||||
* Returns false so the caller falls back to its default (profile switch) —
|
||||
* the number keys mean "switch tab" only while a multi-tab zone holds focus. */
|
||||
* the number keys mean "switch tab" only while a multi-tab zone is targeted. */
|
||||
export function activateTreeTabSlot(slot: number): boolean {
|
||||
const groupId = $activeTreeGroup.get()
|
||||
const groupId = tabTargetGroupId()
|
||||
const tree = $layoutTree.get()
|
||||
const group = groupId && tree ? findGroup(tree, groupId) : null
|
||||
const panes = group ? shownPanesInGroup(group) : []
|
||||
|
|
@ -445,11 +486,11 @@ export function activateTreeTabSlot(slot: number): boolean {
|
|||
return true
|
||||
}
|
||||
|
||||
/** ⌃Tab / ⌃⇧Tab: cycle the FOCUSED zone's *visible* tabs (wrapping) — but only a
|
||||
/** ⌃Tab / ⌃⇧Tab: cycle the target zone's *visible* tabs (wrapping) — but only a
|
||||
* session/main strip with ≥2 shown tabs. Returns false so the caller falls
|
||||
* back to the recent-session switcher when the focus isn't a chat tab strip. */
|
||||
* back to the recent-session switcher when the target isn't a chat tab strip. */
|
||||
export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean {
|
||||
const groupId = $activeTreeGroup.get()
|
||||
const groupId = tabTargetGroupId()
|
||||
const tree = $layoutTree.get()
|
||||
const group = groupId && tree ? findGroup(tree, groupId) : null
|
||||
const panes = group ? shownPanesInGroup(group) : []
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue