From eadbd4be14c34262b182071ec2ba4414a3d5a6d3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=A8=8B=E5=BA=8F=E5=91=98=E9=98=BF=E6=B1=9F=28Relakkes?= =?UTF-8?q?=29?= Date: Fri, 8 May 2026 12:41:04 +0800 Subject: [PATCH] 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. --- desktop/src/components/layout/TabBar.test.tsx | 43 +++++++++++++++++++ desktop/src/components/layout/TabBar.tsx | 15 +++++++ 2 files changed, 58 insertions(+) diff --git a/desktop/src/components/layout/TabBar.test.tsx b/desktop/src/components/layout/TabBar.test.tsx index 784e19bd..4d2bde3b 100644 --- a/desktop/src/components/layout/TabBar.test.tsx +++ b/desktop/src/components/layout/TabBar.test.tsx @@ -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>) + + await act(async () => { + render() + }) + 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') diff --git a/desktop/src/components/layout/TabBar.tsx b/desktop/src/components/layout/TabBar.tsx index 2eaface7..b29a587b 100644 --- a/desktop/src/components/layout/TabBar.tsx +++ b/desktop/src/components/layout/TabBar.tsx @@ -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)