Restore tab close affordance after drag migration

The pointer-drag rewrite left the tab close control without its hover trigger,
so tabs could still be closed logically but no longer exposed the affordance.
This restores the hover group, trims the close icon to a lighter visual weight,
and adds a regression test to keep close-click behavior from colliding with drag.

Constraint: Tab close must remain compatible with the custom pointer-drag reorder flow
Rejected: Reintroduce a larger hoverable close button | made the tab chrome look visually heavy
Confidence: high
Scope-risk: narrow
Reversibility: clean
Directive: Keep the close control visually subordinate to the tab label and verify drag-click interactions before changing tab hit areas
Tested: cd desktop && bun run test -- TabBar; cd desktop && bun run lint
Not-tested: Full desktop app manual visual QA in Tauri runtime
This commit is contained in:
程序员阿江(Relakkes) 2026-04-21 18:38:31 +08:00
parent 47b3cc4d40
commit c05eeff644
2 changed files with 43 additions and 3 deletions

View File

@ -295,4 +295,42 @@ describe('TabBar', () => {
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-1', 'tab-2'])
expect(useTabStore.getState().activeTabId).toBe('tab-1')
})
it('closes a tab from the close button without activating drag behavior', async () => {
const { TabBar } = await import('./TabBar')
const { useTabStore } = await import('../../stores/tabStore')
const { useChatStore } = await import('../../stores/chatStore')
const disconnectSession = vi.fn()
useTabStore.setState({
tabs: [
{ sessionId: 'tab-1', title: 'First Session', type: 'session', status: 'idle' },
{ sessionId: 'tab-2', title: 'Second Session', type: 'session', status: 'idle' },
],
activeTabId: 'tab-2',
})
useChatStore.setState({
sessions: {},
disconnectSession,
} as Partial<ReturnType<typeof useChatStore.getState>>)
await act(async () => {
render(<TabBar />)
})
const firstTab = screen.getByText('First Session').closest('.tab-bar-hit-area')
const closeButton = screen.getByLabelText('Close First Session')
expect(firstTab).toHaveClass('group')
fireEvent.mouseDown(closeButton, { button: 0, clientX: 20, clientY: 10 })
fireEvent.click(closeButton)
fireEvent.mouseMove(window, { clientX: 260, clientY: 10 })
fireEvent.mouseUp(window)
expect(disconnectSession).toHaveBeenCalledWith('tab-1')
expect(useTabStore.getState().tabs.map((tab) => tab.sessionId)).toEqual(['tab-2'])
expect(useTabStore.getState().activeTabId).toBe('tab-2')
})
})

View File

@ -374,7 +374,7 @@ const TabItem = forwardRef<HTMLDivElement, {
onMouseDown={onMouseDown}
onContextMenu={onContextMenu}
className={`
tab-bar-hit-area flex-shrink-0 flex items-center gap-1.5 px-3 min-h-[37px] relative
tab-bar-hit-area group flex-shrink-0 flex items-center gap-1.5 px-3 min-h-[37px] relative
${isDragging ? 'z-20 cursor-grabbing' : 'cursor-grab'}
transition-[background-color,box-shadow,opacity,transform] duration-150 ease-out
${isActive
@ -408,11 +408,13 @@ const TabItem = forwardRef<HTMLDivElement, {
</span>
<button
type="button"
aria-label={`Close ${tab.title || 'Untitled'}`}
onMouseDown={(e) => { e.stopPropagation() }}
onClick={(e) => { e.stopPropagation(); onClose() }}
className="flex-shrink-0 w-4 h-4 flex items-center justify-center rounded opacity-0 group-hover:opacity-100 hover:bg-[var(--color-surface-hover)] transition-opacity text-[var(--color-text-tertiary)] hover:text-[var(--color-text-primary)]"
className="flex-shrink-0 -mr-0.5 inline-flex h-3 w-3 items-center justify-center bg-transparent p-0 opacity-0 group-hover:opacity-100 focus-visible:opacity-100 transition-[opacity,color] text-[var(--color-text-tertiary)] hover:text-[var(--color-text-secondary)] focus-visible:outline-none"
>
<span className="material-symbols-outlined text-[14px]">close</span>
<span className="material-symbols-outlined text-[11px] leading-none">close</span>
</button>
</div>
)