Keep newly activated desktop tabs visible

When the tab strip overflows, newly opened sessions were active but could remain outside the visible horizontal viewport. The tab bar already owns element refs for drag and close behavior, so the focused fix scrolls the active tab into view after activation and refreshes overflow controls on the next frame.

Constraint: Preserve existing tab ordering, drag behavior, and close-button affordances.
Rejected: Reorder new sessions to the left | changes established tab order and makes history harder to scan.
Confidence: high
Scope-risk: narrow
Directive: Keep active-tab visibility behavior in TabBar so all activation paths share it.
Tested: cd desktop && bun run test -- TabBar
Tested: cd desktop && bun run lint
Tested: cd desktop && bun run build
Tested: agent-browser overflow smoke with six tabs confirmed active Untitled Session fullyVisible=true
Not-tested: Full bun run verify gate.
This commit is contained in:
程序员阿江(Relakkes) 2026-05-08 12:41:04 +08:00
parent 12f6cb1b63
commit eadbd4be14
2 changed files with 58 additions and 0 deletions

View File

@ -9,6 +9,7 @@ const getCurrentWindowMock = vi.hoisted(() => vi.fn(() => ({
const windowControlsMock = vi.hoisted(() => ({
show: true,
}))
const scrollIntoViewMock = vi.hoisted(() => vi.fn())
vi.mock('@tauri-apps/api/window', () => ({
getCurrentWindow: getCurrentWindowMock,
@ -64,8 +65,14 @@ describe('TabBar', () => {
value: {},
})
Object.defineProperty(window.HTMLElement.prototype, 'scrollIntoView', {
configurable: true,
value: scrollIntoViewMock,
})
startDraggingMock.mockClear()
getCurrentWindowMock.mockClear()
scrollIntoViewMock.mockClear()
windowControlsMock.show = true
vi.resetModules()
})
@ -88,6 +95,42 @@ describe('TabBar', () => {
delete (window as typeof window & { __TAURI__?: unknown }).__TAURI__
})
it('scrolls the active tab into view when the active tab changes', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
useTabStore.setState({
tabs: [
{ sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' },
{ sessionId: 'tab-2', title: 'Second Session', type: 'session', status: 'idle' },
{ sessionId: 'tab-3', title: 'Third Session', type: 'session', status: 'idle' },
{ sessionId: 'tab-4', title: 'Fourth Session', type: 'session', status: 'idle' },
{ sessionId: 'tab-5', title: 'New Session', type: 'session', status: 'idle' },
],
activeTabId: 'tab-1',
})
useChatStore.setState({
sessions: {},
disconnectSession: vi.fn(),
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
scrollIntoViewMock.mockClear()
await act(async () => {
useTabStore.getState().setActiveTab('tab-5')
})
expect(scrollIntoViewMock).toHaveBeenCalledWith({
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
})
})
it('keeps the overflow button flush against window controls on Windows', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')

View File

@ -93,6 +93,21 @@ export function TabBar() {
}
}, [updateScrollState, tabs.length])
useEffect(() => {
if (!activeTabId) return
const activeTabEl = tabRefs.current.get(activeTabId)
if (!activeTabEl) return
activeTabEl.scrollIntoView({
block: 'nearest',
inline: 'nearest',
behavior: 'smooth',
})
const frame = window.requestAnimationFrame(updateScrollState)
return () => window.cancelAnimationFrame(frame)
}, [activeTabId, tabs.length, updateScrollState])
useEffect(() => {
if (!contextMenu) return
const close = () => setContextMenu(null)