mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
Merge pull request #74447 from NousResearch/bb/hover-tab-slots
Tab verbs follow the pane under the pointer
This commit is contained in:
commit
1d95a227f8
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