fix(desktop): don't hijack the active tab on reactive pane unhide

In the Focus layout `files` shares one tab group with `workspace`, so when the
first reply adopts a cwd the reactive files unhide fronted files and yanked the
active tab off the new session (~1s after the reply). #65375 fixed the sibling
"reactive unhide reopens a collapsed side" bug but frontPaneInGroup still stole
the active slot unconditionally.

Only take the active slot when the group's current active pane isn't itself
showable, so a reactive unhide can't steal focus from a pane the user is
viewing while still fronting a valid tab when nothing is shown.

Carries forward linfeng961's diagnosis + fix from #66870, reworked onto the
frontPaneInGroup path introduced by #65375.

Co-authored-by: linfeng961 <133505766+linfeng961@users.noreply.github.com>
This commit is contained in:
Brooklyn Nicholson 2026-07-18 23:58:42 -04:00
parent 667b98b5cf
commit fbb867f54f
2 changed files with 85 additions and 0 deletions

View file

@ -0,0 +1,76 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
// Focus layout packs `workspace` and `files` into one tab group. A reactive
// unhide of `files` (cwd arrives on the first reply / new session) must NOT
// steal the active tab away from the workspace/new-session the user is looking
// at — the "files panel hijacks the tab ~1s after the first reply" bug.
// frontPaneInGroup only takes the active slot when the current active pane is
// not itself showable, so it still picks a valid tab when nothing is visible.
describe('reactive unhide in a shared (Focus) group', () => {
beforeEach(() => {
window.localStorage.clear()
vi.resetModules()
})
afterEach(() => {
vi.resetModules()
})
async function setupFocusGroup() {
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']) {
registry.register({ id, area: 'panes', title: id, data: {}, render: () => null })
}
// Focus layout: sessions | (workspace + files share one group), workspace
// fronted — mirrors FOCUS_TREE's single-group packing in controller.tsx.
tree.declareDefaultTree(
model.split(
'row',
[
model.group(['sessions'], { id: 'grp-sessions' }),
model.group(['workspace', 'files'], { active: 'workspace', id: 'grp-focus' })
],
[1, 4.6]
)
)
const activeOf = (paneId: string) =>
model.findGroupOfPane(tree.$layoutTree.get()!, paneId)?.active ?? null
return { activeOf, tree }
}
it('does not steal the active tab from workspace when files unhides', async () => {
const { activeOf, tree } = await setupFocusGroup()
// Detached chat: no cwd → files hidden, workspace is the active tab.
tree.setTreePaneHidden('files', true)
expect(activeOf('workspace')).toBe('workspace')
// First reply adopts a cwd → the workspace wiring unhides files.
tree.setTreePaneHidden('files', false)
expect(tree.$hiddenTreePanes.get().has('files')).toBe(false)
// The user stays on the new session; files must not hijack the tab.
expect(activeOf('workspace')).toBe('workspace')
})
it('still fronts the unhidden pane when the active tab is not showable', async () => {
const { activeOf, tree } = await setupFocusGroup()
// Nothing valid showing: the active pane is itself hidden.
tree.setTreePaneHidden('workspace', true)
tree.setTreePaneHidden('files', true)
tree.setTreePaneHidden('files', false)
// With no showable active pane, the reactive unhide picks files so the
// group fronts a valid tab (preserves the #65375 "visible next time" intent).
expect(activeOf('files')).toBe('files')
})
})

View file

@ -149,6 +149,15 @@ function frontPaneInGroup(paneId: string) {
return
}
// Don't steal the active tab from a pane the user is already viewing. In the
// Focus layout `files` shares a group with `workspace`, so a reactive unhide
// (cwd arrives on the first reply) would otherwise yank the active tab off
// the new session onto files. Only take the active slot when the current
// active pane isn't itself showable — then fronting picks a valid tab.
if (group.active && !$hiddenTreePanes.get().has(group.active)) {
return
}
const next = setActivePaneOp(tree, group.id, paneId)
if (next !== tree) {