Merge pull request #73218 from NousResearch/bb/focused-zone-tabs

fix(desktop): make the tab verbs follow the focused zone like ⌘1-9
This commit is contained in:
brooklyn! 2026-07-28 03:14:03 -05:00 committed by GitHub
commit e4564586bc
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 170 additions and 26 deletions

View file

@ -0,0 +1,83 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// ⌘W / ⌘T / ⌘⇧T / the strip "+" used to hardcode the workspace's zone while
// ⌘1…⌘9 followed the interacted zone — so every tab verb missed a SECOND chat
// zone the user was working in. They now resolve the same focused zone.
describe('focused chat zone drives 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', 'files', 'session-tile:a', 'session-tile:b']) {
registry.register({
area: 'panes',
data: id === 'workspace' ? { placement: 'main', uncloseable: true } : { placement: 'main' },
id,
render: () => null,
title: id
})
}
// Two chat zones side by side: main holds the workspace, the second holds
// its own session tabs (a split-off stack).
tree.declareDefaultTree(
model.split('row', [
model.group(['workspace'], { active: 'workspace', id: 'grp-main' }),
model.group(['session-tile:a', 'session-tile:b'], { active: 'session-tile:b', id: 'grp-side' })
])
)
return { model, tree }
}
it('⌘T anchors the new tab to the focused zone, not always the workspace', async () => {
const { tree } = await setup()
tree.noteActiveTreeGroup('grp-side')
expect(tree.focusedSessionTabAnchor()).toBe('session-tile:b')
tree.noteActiveTreeGroup('grp-main')
expect(tree.focusedSessionTabAnchor()).toBe('workspace')
})
it('a non-chat zone (files/terminal focus) falls back to the workspace', async () => {
const { model, tree } = await setup()
tree.declareDefaultTree(
model.split('row', [
model.group(['workspace'], { active: 'workspace', id: 'grp-main' }),
model.group(['files'], { active: 'files', id: 'grp-files' })
])
)
tree.noteActiveTreeGroup('grp-files')
expect(tree.focusedSessionTabAnchor()).toBe('workspace')
// ⌘W must not close the file tree just because it holds focus.
expect(tree.closeFocusedSessionTab()).toBe(false)
})
it('⌘W closes the focused zone active tab and leaves the workspace alone', async () => {
const { model, tree } = await setup()
tree.noteActiveTreeGroup('grp-side')
expect(tree.closeFocusedSessionTab()).toBe(true)
expect(model.allPaneIds(tree.$layoutTree.get()!)).not.toContain('session-tile:b')
// Back on main: the uncloseable workspace is a no-op (the caller promotes
// a stacked session instead of closing the window).
tree.noteActiveTreeGroup('grp-main')
expect(tree.closeFocusedSessionTab()).toBe(false)
expect(model.allPaneIds(tree.$layoutTree.get()!)).toContain('workspace')
})
})

View file

@ -39,7 +39,9 @@ import {
collapseTreePane,
dismissTreePane,
isCollapsePane,
isSessionStripPane,
moveTreePane,
noteActiveTreeGroup,
restoreTreePane,
SESSION_TILE_DRAG,
setTreeGroupHeaderHidden,
@ -507,16 +509,26 @@ export function TreeGroup({
return <Fragment key={paneId}>{chrome.tabWrap ? chrome.tabWrap(tab) : tab}</Fragment>
})}
{/* Plain "+" after the last tab of the MAIN strip (the workspace
zone) always shown, no tab/button chrome, just the glyph.
Creates a new session tab (mirrors T) via the app-registered
action; hidden when unwired or the zone is minimized. */}
{node.panes.includes('workspace') && newSessionTabAction && !node.minimized && (
{/* Plain "+" after the last tab of a CHAT strip (the workspace
zone, or any zone holding session tabs) always shown, no
tab/button chrome, just the glyph. Creates a new session tab
(mirrors T) via the app-registered action; the pointerdown
focuses this zone first, so the tab lands in THIS strip.
Hidden when unwired or the zone is minimized. */}
{shown.some(isSessionStripPane) && newSessionTabAction && !node.minimized && (
<button
aria-label={t.zones.newSessionTab}
className="grid size-7 shrink-0 place-items-center self-center bg-transparent text-(--ui-text-quaternary) transition-colors hover:text-foreground [-webkit-app-region:no-drag]"
onClick={() => newSessionTabAction()}
onPointerDown={e => e.stopPropagation()}
onPointerDown={e => {
e.stopPropagation()
// The action docks into the FOCUSED chat zone; clicking a
// background strip's "+" must make THAT zone the focused
// one first, or the tab opens in whichever zone was last
// clicked. (pointerdown's own focus tracking would land
// after the click handler reads the anchor.)
noteActiveTreeGroup(node.id)
}}
title={t.zones.newSessionTab}
type="button"
>
@ -716,7 +728,7 @@ function ZoneDropOverlay({ node }: { node: GroupNode }) {
// now (stack into its tabs / split its edges); only a CHAT zone's center is
// a link-to-chat (the composer overlay owns that visual).
const sessionDrag = dragging === SESSION_TILE_DRAG
const chatZone = node.panes.some(p => p === 'workspace' || p.startsWith('session-tile:'))
const chatZone = node.panes.some(isSessionStripPane)
const isDragSource = node.panes.includes(dragging)

View file

@ -21,6 +21,7 @@ import {
findGroup,
findGroupOfPane,
groupLeafIds,
type GroupNode,
insertAtGroup,
isLayoutNode,
type LayoutNode,
@ -282,12 +283,49 @@ const isUncloseablePane = (paneId: string): boolean =>
(registry.getArea('panes').find(c => c.id === paneId)?.data as { uncloseable?: boolean } | undefined)?.uncloseable
)
/** W "main tabs always": close the MAIN (workspace) zone's active tab, unless
* it's the uncloseable workspace itself. Returns false when there's nothing to
* close, so W stays a no-op it never closes the window. */
export function closeWorkspaceTab(): boolean {
/** A pane that belongs to a CHAT tab strip — the workspace or a session tile. */
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. */
function focusedSessionGroup(): GroupNode | null {
const tree = $layoutTree.get()
const active = tree ? findGroupOfPane(tree, 'workspace')?.active : null
if (!tree) {
return null
}
const groupId = $activeTreeGroup.get()
const focused = groupId ? findGroup(tree, groupId) : null
return focused?.panes.some(isSessionStripPane) ? focused : findGroupOfPane(tree, 'workspace')
}
/** The pane a NEW session tab should dock beside (T): the focused chat zone's
* active session pane, else its first. Null when no zone hosts a chat strip
* the caller falls back to the workspace. */
export function focusedSessionTabAnchor(): null | string {
const group = focusedSessionGroup()
if (!group) {
return null
}
const active = group.active
return active && isSessionStripPane(active) ? active : (group.panes.find(isSessionStripPane) ?? null)
}
/** W: close the FOCUSED chat zone's active tab, unless it's the uncloseable
* workspace itself. Returns false when there's nothing to close, so W stays a
* no-op it never closes the window. */
export function closeFocusedSessionTab(): boolean {
const active = focusedSessionGroup()?.active
if (!active || isUncloseablePane(active)) {
return false
@ -416,7 +454,7 @@ export function cycleTreeTabInFocusedZone(direction: 1 | -1): boolean {
const group = groupId && tree ? findGroup(tree, groupId) : null
const panes = group ? shownPanesInGroup(group) : []
if (panes.length < 2 || !panes.some(id => id === 'workspace' || id.startsWith('session-tile:'))) {
if (panes.length < 2 || !panes.some(isSessionStripPane)) {
return false
}