Merge pull request #74447 from NousResearch/bb/hover-tab-slots

Tab verbs follow the pane under the pointer
This commit is contained in:
brooklyn! 2026-07-29 18:35:59 -05:00 committed by GitHub
commit 1d95a227f8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 153 additions and 22 deletions

View file

@ -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')
})
})

View file

@ -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 19 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 19 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 19 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[] {
})
}
/** 19: 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).
/** 19: 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) : []