mirror of
https://github.com/stablyai/orca.git
synced 2026-10-30 08:37:49 +00:00
fix(workspace-board): close the board when a right-sidebar tab is clicked (#30063)
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com> Co-authored-by: Neil <neil@stably.ai>
This commit is contained in:
co-authored by
Claude Opus 5.5
Kelvin Amoaba
Neil
parent
3b380ddfcf
commit
de37a4cbe8
@@ -100,6 +100,7 @@ export function TopActivityOverflowMenu({
|
||||
<DropdownMenuItem
|
||||
key={item.id}
|
||||
onSelect={() => onSelect(item.id)}
|
||||
data-workspace-board-dismiss=""
|
||||
className={cn(active && 'bg-accent text-accent-foreground')}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
aria-label={activityItemAriaLabel(item, item.statusIndicator)}
|
||||
@@ -150,6 +151,7 @@ export function ActivityBarButton({
|
||||
active ? 'text-foreground' : 'text-muted-foreground/60 hover:text-muted-foreground'
|
||||
)}
|
||||
onClick={onClick}
|
||||
data-workspace-board-dismiss=""
|
||||
aria-label={activityItemAriaLabel(item, effectiveStatus)}
|
||||
>
|
||||
<Icon size={isTop ? 16 : 18} />
|
||||
|
||||
@@ -1,61 +1,128 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { isWorkspaceBoardKeepOpenTarget } from './use-workspace-kanban-outside-dismiss'
|
||||
// @vitest-environment happy-dom
|
||||
|
||||
class FakeNode {
|
||||
parentElement: FakeElement | null = null
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { cleanup, renderHook } from '@testing-library/react'
|
||||
import {
|
||||
isWorkspaceBoardKeepOpenTarget,
|
||||
useWorkspaceKanbanOutsideDismiss
|
||||
} from './use-workspace-kanban-outside-dismiss'
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
document.body.replaceChildren()
|
||||
})
|
||||
|
||||
function mountBoardListener({
|
||||
open = true,
|
||||
preserveOpenForMenu = false
|
||||
}: { open?: boolean; preserveOpenForMenu?: boolean } = {}) {
|
||||
const sheet = document.createElement('div')
|
||||
sheet.setAttribute('data-slot', 'sheet-content')
|
||||
const board = document.createElement('div')
|
||||
sheet.append(board)
|
||||
document.body.append(sheet)
|
||||
vi.spyOn(sheet, 'getBoundingClientRect').mockReturnValue(new DOMRect(100, 36, 800, 600))
|
||||
const onOpenChange = vi.fn()
|
||||
const hook = renderHook(() =>
|
||||
useWorkspaceKanbanOutsideDismiss({
|
||||
open,
|
||||
boardRef: { current: board },
|
||||
preserveOpenForMenu,
|
||||
onOpenChange
|
||||
})
|
||||
)
|
||||
return { board, onOpenChange, unmount: hook.unmount }
|
||||
}
|
||||
|
||||
class FakeElement extends FakeNode {
|
||||
private readonly attributes: ReadonlySet<string>
|
||||
|
||||
constructor(attributes: readonly string[] = [], parentElement: FakeElement | null = null) {
|
||||
super()
|
||||
this.attributes = new Set(attributes)
|
||||
this.parentElement = parentElement
|
||||
}
|
||||
|
||||
closest(selector: string): FakeElement | null {
|
||||
if (this.matches(selector)) {
|
||||
return this
|
||||
}
|
||||
return this.parentElement?.closest(selector) ?? null
|
||||
}
|
||||
|
||||
private matches(selector: string): boolean {
|
||||
return selector
|
||||
.split(',')
|
||||
.map((part) => part.trim())
|
||||
.some((part) => this.attributes.has(part))
|
||||
}
|
||||
function sidebarTab(): HTMLButtonElement {
|
||||
const tab = document.createElement('button')
|
||||
tab.setAttribute('data-workspace-board-dismiss', '')
|
||||
document.body.append(tab)
|
||||
return tab
|
||||
}
|
||||
|
||||
describe('workspace kanban outside dismiss keep-open targets', () => {
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('Node', FakeNode)
|
||||
vi.stubGlobal('Element', FakeElement)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('keeps the board open when a Sonner toast action is clicked', () => {
|
||||
const toast = new FakeElement(['[data-sonner-toast]'])
|
||||
const action = new FakeElement([], toast)
|
||||
|
||||
expect(isWorkspaceBoardKeepOpenTarget(action as unknown as EventTarget)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps the board open when the contextual tour panel is clicked', () => {
|
||||
const panel = new FakeElement(['[data-contextual-tour-panel]'])
|
||||
const nextButton = new FakeElement([], panel)
|
||||
|
||||
expect(isWorkspaceBoardKeepOpenTarget(nextButton as unknown as EventTarget)).toBe(true)
|
||||
})
|
||||
it.each(['data-sonner-toast', 'data-contextual-tour-panel'])(
|
||||
'keeps the board open for a child of %s',
|
||||
(attribute) => {
|
||||
const panel = document.createElement('div')
|
||||
panel.setAttribute(attribute, '')
|
||||
const action = document.createElement('button')
|
||||
panel.append(action)
|
||||
expect(isWorkspaceBoardKeepOpenTarget(action)).toBe(true)
|
||||
}
|
||||
)
|
||||
|
||||
it('does not keep the board open for generic outside content', () => {
|
||||
const target = new FakeElement()
|
||||
expect(isWorkspaceBoardKeepOpenTarget(document.createElement('div'))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
expect(isWorkspaceBoardKeepOpenTarget(target as unknown as EventTarget)).toBe(false)
|
||||
describe('workspace kanban outside dismiss', () => {
|
||||
it.each(['pointerdown', 'click'])(
|
||||
'closes from a sidebar tab inside a portalled menu on %s',
|
||||
(eventType) => {
|
||||
const { onOpenChange } = mountBoardListener()
|
||||
const menu = document.createElement('div')
|
||||
menu.setAttribute('data-slot', 'dropdown-menu-content')
|
||||
const tab = sidebarTab()
|
||||
const icon = document.createElement('span')
|
||||
tab.append(icon)
|
||||
menu.append(tab)
|
||||
document.body.append(menu)
|
||||
|
||||
icon.dispatchEvent(new MouseEvent(eventType, { bubbles: true }))
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
}
|
||||
)
|
||||
|
||||
it('dismisses for an explicit sidebar tab while a board menu is open', () => {
|
||||
const { onOpenChange } = mountBoardListener({ preserveOpenForMenu: true })
|
||||
|
||||
sidebarTab().dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('preserves board content, unrelated menus and the existing outside bounds', () => {
|
||||
const { board, onOpenChange } = mountBoardListener()
|
||||
const markerInsideBoard = sidebarTab()
|
||||
board.append(markerInsideBoard)
|
||||
markerInsideBoard.click()
|
||||
const menu = document.createElement('div')
|
||||
menu.setAttribute('data-slot', 'dropdown-menu-content')
|
||||
document.body.append(menu)
|
||||
menu.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, clientX: 950, clientY: 80 })
|
||||
)
|
||||
document.body.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, clientX: 500, clientY: 18 })
|
||||
)
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
|
||||
document.body.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, clientX: 950, clientY: 80 })
|
||||
)
|
||||
expect(onOpenChange).toHaveBeenCalledWith(false)
|
||||
})
|
||||
|
||||
it('preserves an open board menu for ordinary outside pointer events', () => {
|
||||
const { onOpenChange } = mountBoardListener({ preserveOpenForMenu: true })
|
||||
document.body.dispatchEvent(
|
||||
new PointerEvent('pointerdown', { bubbles: true, clientX: 950, clientY: 80 })
|
||||
)
|
||||
expect(onOpenChange).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops listening after unmount and while the board is closed', () => {
|
||||
const mounted = mountBoardListener()
|
||||
mounted.unmount()
|
||||
const closed = mountBoardListener({ open: false })
|
||||
const tab = sidebarTab()
|
||||
tab.dispatchEvent(new PointerEvent('pointerdown', { bubbles: true }))
|
||||
tab.click()
|
||||
expect(mounted.onOpenChange).not.toHaveBeenCalled()
|
||||
expect(closed.onOpenChange).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -19,9 +19,12 @@ const WORKSPACE_BOARD_KEEP_OPEN_SELECTOR = [
|
||||
'[role="menu"][data-state="open"]'
|
||||
].join(', ')
|
||||
|
||||
function eventTargetElement(target: EventTarget | null): Element | null {
|
||||
return target instanceof Element ? target : target instanceof Node ? target.parentElement : null
|
||||
}
|
||||
|
||||
export function isWorkspaceBoardKeepOpenTarget(target: EventTarget | null): boolean {
|
||||
const element =
|
||||
target instanceof Element ? target : target instanceof Node ? target.parentElement : null
|
||||
const element = eventTargetElement(target)
|
||||
// Why: board-owned menus and confirmation dialogs portal to document.body,
|
||||
// so DOM containment alone would treat their clicks/focus as board exits.
|
||||
return Boolean(element?.closest(WORKSPACE_BOARD_KEEP_OPEN_SELECTOR))
|
||||
@@ -40,15 +43,24 @@ export function useWorkspaceKanbanOutsideDismiss(params: {
|
||||
return
|
||||
}
|
||||
|
||||
const handlePointerDown = (event: PointerEvent): void => {
|
||||
const handleOutsideInteraction = (event: MouseEvent): void => {
|
||||
const content = boardRef.current?.closest<HTMLElement>('[data-slot="sheet-content"]')
|
||||
if (!content || preserveOpenForMenu) {
|
||||
if (!content) {
|
||||
return
|
||||
}
|
||||
if (event.target instanceof Node && content.contains(event.target)) {
|
||||
return
|
||||
}
|
||||
if (isWorkspaceBoardKeepOpenTarget(event.target)) {
|
||||
// Why: titlebar tabs switch content the board covers, so it must close to show it.
|
||||
if (eventTargetElement(event.target)?.closest('[data-workspace-board-dismiss]')) {
|
||||
onOpenChange(false)
|
||||
return
|
||||
}
|
||||
if (
|
||||
preserveOpenForMenu ||
|
||||
event.type === 'click' ||
|
||||
isWorkspaceBoardKeepOpenTarget(event.target)
|
||||
) {
|
||||
return
|
||||
}
|
||||
const rect = content.getBoundingClientRect()
|
||||
@@ -57,7 +69,11 @@ export function useWorkspaceKanbanOutsideDismiss(params: {
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('pointerdown', handlePointerDown, true)
|
||||
return () => document.removeEventListener('pointerdown', handlePointerDown, true)
|
||||
document.addEventListener('pointerdown', handleOutsideInteraction, true)
|
||||
document.addEventListener('click', handleOutsideInteraction, true)
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', handleOutsideInteraction, true)
|
||||
document.removeEventListener('click', handleOutsideInteraction, true)
|
||||
}
|
||||
}, [boardRef, onOpenChange, open, preserveOpenForMenu])
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import { test, expect } from './helpers/orca-app'
|
||||
import { waitForActiveWorktree, waitForSessionReady } from './helpers/store'
|
||||
|
||||
// Why: on narrow windows the titlebar tab strip switches panels the board covers.
|
||||
test.describe('Workspace board right-sidebar tab dismiss', () => {
|
||||
test.skip(process.platform !== 'darwin', 'Linux and Windows draw the tab strip under the board')
|
||||
|
||||
test.beforeEach(async ({ orcaPage }) => {
|
||||
await waitForSessionReady(orcaPage)
|
||||
await waitForActiveWorktree(orcaPage)
|
||||
})
|
||||
|
||||
test('clicking a right-sidebar tab closes the board and shows that tab', async ({
|
||||
electronApp,
|
||||
orcaPage
|
||||
}) => {
|
||||
await electronApp.evaluate(({ BrowserWindow }) => {
|
||||
BrowserWindow.getAllWindows()[0].setSize(1000, 800)
|
||||
})
|
||||
await orcaPage.evaluate(() => {
|
||||
const state = window.__store?.getState()
|
||||
if (!state) {
|
||||
throw new Error('window.__store is not available')
|
||||
}
|
||||
state.setSidebarOpen(true)
|
||||
state.setRightSidebarOpen(true)
|
||||
state.setRightSidebarTab('explorer')
|
||||
})
|
||||
|
||||
await orcaPage.getByRole('button', { name: 'Workspace board' }).click()
|
||||
const board = orcaPage.locator('[data-workspace-board-sheet]')
|
||||
await expect(board).toBeVisible()
|
||||
await board.evaluate((el) => Promise.all(el.getAnimations().map((a) => a.finished)))
|
||||
|
||||
await orcaPage.getByRole('button', { name: /^Source Control/ }).click()
|
||||
|
||||
await expect(board).toBeHidden()
|
||||
await expect(orcaPage.getByTestId('source-control-filter-toggle')).toBeVisible()
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user