diff --git a/apps/desktop/src/components/pane-shell/tree/hovered-zone-tabs.test.ts b/apps/desktop/src/components/pane-shell/tree/hovered-zone-tabs.test.ts new file mode 100644 index 00000000000..4112e181614 --- /dev/null +++ b/apps/desktop/src/components/pane-shell/tree/hovered-zone-tabs.test.ts @@ -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') + }) +}) diff --git a/apps/desktop/src/components/pane-shell/tree/store.ts b/apps/desktop/src/components/pane-shell/tree/store.ts index 8b760d69f06..aed1ccc4b00 100644 --- a/apps/desktop/src/components/pane-shell/tree/store.ts +++ b/apps/desktop/src/components/pane-shell/tree/store.ts @@ -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) + +/** 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('[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('[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) : []