Move workspace board to sidebar bottom toolbar (#5146)

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Brennan Benson
2026-06-11 12:52:48 -07:00
committed by Jinwoo-H
co-authored by Orca
parent f93549fbf1
commit 787c42f92a
16 changed files with 554 additions and 144 deletions
+2 -4
View File
@@ -187,7 +187,7 @@ export default function MobileFileExplorerScreen() {
setOpeningPath(null)
}
},
[client, hostId, name, router, worktreeId]
[client, router, worktreeId]
)
const renderItem: ListRenderItem<TreeNode> = ({ item }) => {
@@ -348,9 +348,7 @@ const styles = StyleSheet.create({
color: colors.textSecondary,
fontSize: typography.metaSize
},
list: {
flex: 1
},
list: { flex: 1 },
listContent: {
paddingVertical: spacing.sm
},
@@ -20,7 +20,7 @@ export function ScrollToCurrentWorkspaceToolbarButton(): React.JSX.Element {
onClick={requestScrollToCurrentWorkspaceReveal}
className="text-muted-foreground"
>
<Crosshair className="size-3.5" />
<Crosshair className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
@@ -1,95 +1,26 @@
import React, { useCallback, useEffect, useRef, useState } from 'react'
import { Kanban, Plus } from 'lucide-react'
import React from 'react'
import { FolderPlus, Plus } from 'lucide-react'
import { useAppStore } from '@/store'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import SidebarWorkspaceOptionsMenu from './SidebarWorkspaceOptionsMenu'
import SidebarHostScopeStrip from './SidebarHostScopeStrip'
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
import { useShortcutLabel } from '@/hooks/useShortcutLabel'
import { openWorkspaceCreationComposerWithTourHandoff } from '../contextual-tours/workspace-creation-tour-handoff'
import { translate } from '@/i18n/i18n'
const SidebarHeader = React.memo(function SidebarHeader() {
type SidebarHeaderProps = {
onWorkspaceBoardMenuOpenChange: (open: boolean) => void
}
const SidebarHeader = React.memo(function SidebarHeader({
onWorkspaceBoardMenuOpenChange
}: SidebarHeaderProps) {
const openModal = useAppStore((s) => s.openModal)
const newWorktreeShortcutLabel = useShortcutLabel('workspace.create')
const [workspaceBoardOpen, setWorkspaceBoardOpen] = useState(false)
const [workspaceBoardMenuOpen, setWorkspaceBoardMenuOpen] = useState(false)
const workspaceBoardOpenRef = useRef(workspaceBoardOpen)
const groupBy = useAppStore((s) => s.groupBy)
const canCreateWorkspace = useAppStore((s) => s.repos.length > 0)
const sidebarTitle = groupBy === 'repo' ? 'Projects' : 'Workspaces'
workspaceBoardOpenRef.current = workspaceBoardOpen
const openWorkspaceBoard = useCallback(() => {
if (workspaceBoardOpenRef.current) {
return
}
workspaceBoardOpenRef.current = true
// Why: opening the board is the user action; recording here avoids a
// post-render bookkeeping Effect in the drawer.
useAppStore.getState().recordFeatureInteraction('workspace-board')
setWorkspaceBoardOpen(true)
}, [])
const closeWorkspaceBoard = useCallback(() => {
workspaceBoardOpenRef.current = false
setWorkspaceBoardOpen(false)
setWorkspaceBoardMenuOpen(false)
}, [])
const handleWorkspaceBoardOpenChange = useCallback(
(open: boolean) => {
if (open) {
openWorkspaceBoard()
return
}
closeWorkspaceBoard()
},
[closeWorkspaceBoard, openWorkspaceBoard]
)
const handleWorkspaceBoardToggle = useCallback(() => {
if (workspaceBoardOpen) {
closeWorkspaceBoard()
return
}
openWorkspaceBoard()
}, [closeWorkspaceBoard, openWorkspaceBoard, workspaceBoardOpen])
useEffect(() => {
if (!workspaceBoardOpen) {
return
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') {
return
}
if (workspaceBoardMenuOpen) {
return
}
// Why: Escape must dismiss any nested overlay (Radix dropdown, popover,
// tooltip, dialog, context menu) ahead of collapsing this non-modal
// companion panel. Radix portals open popper content into a wrapper
// element, and dialogs/menus expose `data-state="open"` on their
// content node, so the presence of either signals the user's intent
// is to dismiss that overlay rather than the workspace board.
if (
document.querySelector(
'[data-radix-popper-content-wrapper], [role="dialog"][data-state="open"], [role="alertdialog"][data-state="open"], [role="menu"][data-state="open"], [role="listbox"][data-state="open"]'
)
) {
return
}
event.preventDefault()
closeWorkspaceBoard()
}
// Why: the workspace board is a non-modal companion panel, so focus may
// be outside the sheet when Escape should still dismiss it.
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [closeWorkspaceBoard, workspaceBoardMenuOpen, workspaceBoardOpen])
return (
<>
@@ -102,33 +33,26 @@ const SidebarHeader = React.memo(function SidebarHeader() {
<div className="flex items-center gap-1.5 shrink-0">
<SidebarWorkspaceOptionsMenu
preserveWorkspaceBoardOpen
onMenuOpenChange={setWorkspaceBoardMenuOpen}
onMenuOpenChange={onWorkspaceBoardMenuOpenChange}
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
variant={workspaceBoardOpen ? 'secondary' : 'ghost'}
variant="ghost"
size="icon-xs"
className="text-muted-foreground"
aria-label={translate(
'auto.components.sidebar.SidebarHeader.49f62c5665',
'Workspace board'
'auto.components.sidebar.SidebarHeader.25a95899c9',
'Add Project'
)}
aria-pressed={workspaceBoardOpen}
data-workspace-board-trigger=""
onClick={handleWorkspaceBoardToggle}
onClick={() => openModal('add-repo')}
>
<Kanban className="size-3.5" strokeWidth={2.25} />
<FolderPlus className="size-3.5" strokeWidth={2.25} />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
{workspaceBoardOpen
? translate(
'auto.components.sidebar.SidebarHeader.a30e34eb5c',
'Close workspace board'
)
: translate('auto.components.sidebar.SidebarHeader.49f62c5665', 'Workspace board')}
{translate('auto.components.sidebar.SidebarHeader.25a95899c9', 'Add Project')}
</TooltipContent>
</Tooltip>
@@ -138,6 +62,9 @@ const SidebarHeader = React.memo(function SidebarHeader() {
variant="ghost"
size="icon-xs"
onClick={() => {
if (!canCreateWorkspace) {
return
}
// Why: the parallel-work tour must click the real sidebar
// control so it can hand off to the workspace-creation tour.
openWorkspaceCreationComposerWithTourHandoff()
@@ -168,12 +95,6 @@ const SidebarHeader = React.memo(function SidebarHeader() {
</div>
</div>
<SidebarHostScopeStrip />
<WorkspaceKanbanDrawer
open={workspaceBoardOpen}
preserveOpenForMenu={workspaceBoardMenuOpen}
onOpenChange={handleWorkspaceBoardOpenChange}
onMenuOpenChange={setWorkspaceBoardMenuOpen}
/>
</>
)
})
@@ -173,7 +173,7 @@ export function SidebarSettingsHelpMenu(): React.JSX.Element {
onPointerDown={(event) => revealAdminOptions(event.altKey)}
onClick={(event) => revealAdminOptions(event.altKey)}
>
<CircleHelp className="size-3.5" />
<CircleHelp className="size-4" />
</Button>
</DropdownMenuTrigger>
</TooltipTrigger>
@@ -0,0 +1,125 @@
// @vitest-environment happy-dom
import { act, type ReactNode } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { AppState } from '@/store'
import SidebarToolbar from './SidebarToolbar'
const mocks = vi.hoisted(() => ({
activeTooltipOpen: false,
state: {
persistedUIReady: true,
featureInteractions: {}
} as Partial<AppState>
}))
vi.mock('@/store', () => ({
useAppStore: (selector: (state: Partial<AppState>) => unknown) => selector(mocks.state)
}))
vi.mock('@/components/ui/tooltip', () => ({
Tooltip: ({ children, open }: { children: ReactNode; open?: boolean }) => {
mocks.activeTooltipOpen = open === true
return <>{children}</>
},
TooltipTrigger: ({ children }: { children: ReactNode }) => <>{children}</>,
TooltipContent: ({ children }: { children: ReactNode }) =>
mocks.activeTooltipOpen ? <span>{children}</span> : null
}))
vi.mock('./ScrollToCurrentWorkspaceToolbarButton', () => ({
ScrollToCurrentWorkspaceToolbarButton: () => <button type="button">Current workspace</button>
}))
vi.mock('./SidebarSettingsHelpMenu', () => ({
SidebarSettingsHelpMenu: () => <button type="button">Settings</button>
}))
const roots: Root[] = []
async function renderToolbar(onWorkspaceBoardToggle = vi.fn()): Promise<{
container: HTMLDivElement
rerender: () => Promise<void>
onWorkspaceBoardToggle: ReturnType<typeof vi.fn>
}> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
const render = async (): Promise<void> => {
await act(async () => {
root.render(
<SidebarToolbar
workspaceBoardOpen={false}
onWorkspaceBoardToggle={onWorkspaceBoardToggle}
/>
)
})
}
await render()
return { container, rerender: render, onWorkspaceBoardToggle }
}
describe('SidebarToolbar moved workspace board hint', () => {
beforeEach(() => {
globalThis.IS_REACT_ACT_ENVIRONMENT = true
window.localStorage.clear()
mocks.activeTooltipOpen = false
mocks.state = {
persistedUIReady: true,
featureInteractions: {}
}
})
afterEach(() => {
roots.splice(0).forEach((root) => {
act(() => root.unmount())
})
document.body.replaceChildren()
vi.clearAllMocks()
})
it('does not show the moved hint to brand-new users after their first board click', async () => {
const onWorkspaceBoardToggle = vi.fn(() => {
mocks.state = {
...mocks.state,
featureInteractions: {
'workspace-board': { firstInteractedAt: Date.now(), interactionCount: 1 }
}
}
})
const { container, rerender } = await renderToolbar(onWorkspaceBoardToggle)
expect(container.textContent).not.toContain('Workspace board moved to the bottom bar')
const boardButton = container.querySelector<HTMLButtonElement>(
'button[aria-label="Workspace board"]'
)
expect(boardButton).not.toBeNull()
await act(async () => {
boardButton?.click()
})
await rerender()
expect(onWorkspaceBoardToggle).toHaveBeenCalledOnce()
expect(container.textContent).not.toContain('Workspace board moved to the bottom bar')
expect(window.localStorage.getItem('orca.workspaceBoardMovedHintSeen.v1')).toBeNull()
})
it('shows the moved hint once to users who had already used the workspace board', async () => {
mocks.state = {
persistedUIReady: true,
featureInteractions: {
'workspace-board': { firstInteractedAt: 100, interactionCount: 2 }
}
}
const { container } = await renderToolbar()
expect(container.textContent).toContain('Workspace board moved to the bottom bar')
expect(window.localStorage.getItem('orca.workspaceBoardMovedHintSeen.v1')).toBe('true')
})
})
@@ -1,42 +1,106 @@
import React from 'react'
import { FolderPlus } from 'lucide-react'
import { useAppStore } from '@/store'
import { Kanban } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Tooltip, TooltipTrigger, TooltipContent } from '@/components/ui/tooltip'
import { ScrollToCurrentWorkspaceToolbarButton } from './ScrollToCurrentWorkspaceToolbarButton'
import { SidebarSettingsHelpMenu } from './SidebarSettingsHelpMenu'
import { translate } from '@/i18n/i18n'
import { useAppStore } from '@/store'
import { hasFeatureInteraction } from '../../../../shared/feature-interactions'
const SidebarToolbar = React.memo(function SidebarToolbar() {
const openModal = useAppStore((s) => s.openModal)
const WORKSPACE_BOARD_MOVED_HINT_STORAGE_KEY = 'orca.workspaceBoardMovedHintSeen.v1'
const WORKSPACE_BOARD_MOVED_HINT_DURATION_MS = 12000
type SidebarToolbarProps = {
workspaceBoardOpen: boolean
onWorkspaceBoardToggle: () => void
}
const SidebarToolbar = React.memo(function SidebarToolbar({
workspaceBoardOpen,
onWorkspaceBoardToggle
}: SidebarToolbarProps) {
const [workspaceBoardMovedHintOpen, setWorkspaceBoardMovedHintOpen] = React.useState(false)
const movedHintEligibleRef = React.useRef<boolean | null>(null)
const persistedUIReady = useAppStore((state) => state.persistedUIReady)
const hasUsedWorkspaceBoard = useAppStore((state) =>
hasFeatureInteraction(state.featureInteractions, 'workspace-board')
)
React.useEffect(() => {
if (!persistedUIReady) {
return
}
// Why: only users who had already opened the old board location should
// see the relocation hint; first-time users should not become eligible.
if (movedHintEligibleRef.current === null) {
movedHintEligibleRef.current = hasUsedWorkspaceBoard
}
if (!movedHintEligibleRef.current) {
return
}
try {
if (window.localStorage.getItem(WORKSPACE_BOARD_MOVED_HINT_STORAGE_KEY) === 'true') {
return
}
window.localStorage.setItem(WORKSPACE_BOARD_MOVED_HINT_STORAGE_KEY, 'true')
} catch {
return
}
setWorkspaceBoardMovedHintOpen(true)
const timeoutId = window.setTimeout(() => {
setWorkspaceBoardMovedHintOpen(false)
}, WORKSPACE_BOARD_MOVED_HINT_DURATION_MS)
return () => window.clearTimeout(timeoutId)
}, [hasUsedWorkspaceBoard, persistedUIReady])
const handleWorkspaceBoardClick = (): void => {
setWorkspaceBoardMovedHintOpen(false)
onWorkspaceBoardToggle()
}
return (
<div className="mt-auto shrink-0">
<div className="flex items-center justify-between border-t border-worktree-sidebar-border px-2 py-1.5">
<Tooltip>
<TooltipTrigger asChild>
<Button
variant="ghost"
size="xs"
onClick={() => openModal('add-repo')}
className="gap-1.5 text-muted-foreground"
>
<FolderPlus className="size-3.5" />
<span className="text-[11px]">
{translate('auto.components.sidebar.SidebarToolbar.abc62b6328', 'Add Project')}
</span>
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{translate(
'auto.components.sidebar.SidebarToolbar.19e32d0e5f',
'Open folder picker to add a project'
)}
</TooltipContent>
</Tooltip>
<div className="flex items-center gap-1">
<SidebarSettingsHelpMenu />
<div className="flex items-center gap-2">
<ScrollToCurrentWorkspaceToolbarButton />
<SidebarSettingsHelpMenu />
<Tooltip open={workspaceBoardMovedHintOpen ? true : undefined}>
<TooltipTrigger asChild>
<Button
variant={workspaceBoardOpen ? 'secondary' : 'ghost'}
size="icon-xs"
type="button"
aria-label={translate(
'auto.components.sidebar.SidebarToolbar.49f62c5665',
'Workspace board'
)}
aria-pressed={workspaceBoardOpen}
data-workspace-board-trigger=""
onClick={handleWorkspaceBoardClick}
className="text-muted-foreground"
>
<Kanban className="size-4" />
</Button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={4}>
{workspaceBoardMovedHintOpen
? translate(
'auto.components.sidebar.SidebarToolbar.87d0064026',
'Workspace board moved to the bottom bar'
)
: workspaceBoardOpen
? translate(
'auto.components.sidebar.SidebarToolbar.a30e34eb5c',
'Close workspace board'
)
: translate(
'auto.components.sidebar.SidebarToolbar.49f62c5665',
'Workspace board'
)}
</TooltipContent>
</Tooltip>
</div>
</div>
</div>
@@ -490,6 +490,7 @@ export default function WorkspaceKanbanDrawer({
} as React.CSSProperties
}
data-contextual-tour-target="workspace-board-surface"
data-workspace-board-sheet=""
onOpenAutoFocus={(event) => {
// Why: Radix focuses the first toolbar button on open, which opens
// its tooltip without hover and makes the drawer feel noisy.
+29 -2
View File
@@ -7,10 +7,12 @@ import SidebarNav from './SidebarNav'
import SetupScriptPromptCard from './SetupScriptPromptCard'
import WorktreeList from './WorktreeList'
import SidebarToolbar from './SidebarToolbar'
import WorkspaceKanbanDrawer from './WorkspaceKanbanDrawer'
import type { VirtualizedScrollAnchor } from '@/hooks/useVirtualizedScrollAnchor'
import { cn } from '@/lib/utils'
import { FolderPlus, Loader2 } from 'lucide-react'
import { useSidebarProjectDrop } from './useSidebarProjectDrop'
import { useWorkspaceBoardPanel } from './useWorkspaceBoardPanel'
const WorktreeMetaDialog = React.lazy(() => import('./WorktreeMetaDialog'))
const NonGitFolderDialog = React.lazy(() => import('./NonGitFolderDialog'))
@@ -45,6 +47,14 @@ function Sidebar({
const { nativeDropTarget, dropHandlers, affordance } = useSidebarProjectDrop()
const [shouldMountAddRepoDialog, setShouldMountAddRepoDialog] = React.useState(false)
const unmountAddRepoDialogTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)
const {
workspaceBoardOpen,
workspaceBoardMenuOpen,
toggleWorkspaceBoard,
handleWorkspaceBoardOpenChange,
setWorkspaceBoardMenuOpen,
closeWorkspaceBoard
} = useWorkspaceBoardPanel()
const setLiveSidebarWidth = React.useCallback((width: number) => {
document.documentElement.style.setProperty('--workspace-sidebar-live-width', `${width}px`)
@@ -83,6 +93,12 @@ function Sidebar({
}
}, [activeModal, shouldMountAddRepoDialog])
useEffect(() => {
if (!sidebarOpen && workspaceBoardOpen) {
closeWorkspaceBoard()
}
}, [closeWorkspaceBoard, sidebarOpen, workspaceBoardOpen])
const { containerRef, onResizeStart } = useSidebarResize<HTMLDivElement>({
isOpen: sidebarOpen,
width: sidebarWidth,
@@ -105,7 +121,7 @@ function Sidebar({
<>
{/* Fixed controls */}
<SidebarNav />
<SidebarHeader />
<SidebarHeader onWorkspaceBoardMenuOpenChange={setWorkspaceBoardMenuOpen} />
<WorktreeList
scrollOffsetRef={worktreeScrollOffsetRef}
@@ -115,7 +131,10 @@ function Sidebar({
<SetupScriptPromptCard />
{/* Fixed bottom toolbar */}
<SidebarToolbar />
<SidebarToolbar
workspaceBoardOpen={workspaceBoardOpen}
onWorkspaceBoardToggle={toggleWorkspaceBoard}
/>
</>
)}
@@ -160,6 +179,14 @@ function Sidebar({
{activeModal === 'worktree-visibility' ? <WorktreeVisibilityDialog /> : null}
{activeModal === 'confirm-orca-yaml-hooks' ? <OrcaYamlTrustDialog /> : null}
</React.Suspense>
{sidebarOpen ? (
<WorkspaceKanbanDrawer
open={workspaceBoardOpen}
preserveOpenForMenu={workspaceBoardMenuOpen}
onOpenChange={handleWorkspaceBoardOpenChange}
onMenuOpenChange={setWorkspaceBoardMenuOpen}
/>
) : null}
</TooltipProvider>
)
}
@@ -0,0 +1,151 @@
// @vitest-environment happy-dom
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { useWorkspaceBoardPanel, type WorkspaceBoardPanelState } from './useWorkspaceBoardPanel'
const mocks = vi.hoisted(() => ({
recordFeatureInteraction: vi.fn()
}))
vi.mock('@/store', () => ({
useAppStore: {
getState: () => ({
recordFeatureInteraction: mocks.recordFeatureInteraction
})
}
}))
let latestState: WorkspaceBoardPanelState | null = null
const roots: Root[] = []
function HookProbe(): null {
latestState = useWorkspaceBoardPanel()
return null
}
async function renderHookProbe(): Promise<void> {
const container = document.createElement('div')
document.body.appendChild(container)
const root = createRoot(container)
roots.push(root)
await act(async () => {
root.render(<HookProbe />)
})
}
function panelState(): WorkspaceBoardPanelState {
if (!latestState) {
throw new Error('Hook state has not been rendered')
}
return latestState
}
async function updatePanel(update: (state: WorkspaceBoardPanelState) => void): Promise<void> {
await act(async () => {
update(panelState())
})
}
async function pressEscape(): Promise<void> {
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
})
}
describe('useWorkspaceBoardPanel', () => {
beforeEach(() => {
latestState = null
mocks.recordFeatureInteraction.mockReset()
})
afterEach(() => {
roots.splice(0).forEach((root) => {
act(() => root.unmount())
})
document.body.replaceChildren()
})
it('toggles the board and records the feature interaction when opened', async () => {
await renderHookProbe()
await updatePanel((state) => state.toggleWorkspaceBoard())
expect(panelState().workspaceBoardOpen).toBe(true)
expect(mocks.recordFeatureInteraction).toHaveBeenCalledExactlyOnceWith('workspace-board')
await updatePanel((state) => state.toggleWorkspaceBoard())
expect(panelState().workspaceBoardOpen).toBe(false)
expect(mocks.recordFeatureInteraction).toHaveBeenCalledOnce()
})
it('keeps the board open on Escape while a nested board menu is open', async () => {
await renderHookProbe()
await updatePanel((state) => state.openWorkspaceBoard())
await updatePanel((state) => state.setWorkspaceBoardMenuOpen(true))
await pressEscape()
expect(panelState().workspaceBoardOpen).toBe(true)
await updatePanel((state) => state.setWorkspaceBoardMenuOpen(false))
await pressEscape()
expect(panelState().workspaceBoardOpen).toBe(false)
})
it('lets Escape close the board while non-interactive tooltip content is open', async () => {
await renderHookProbe()
const tooltip = document.createElement('div')
tooltip.setAttribute('data-slot', 'tooltip-content')
tooltip.setAttribute('data-state', 'open')
document.body.appendChild(tooltip)
await updatePanel((state) => state.openWorkspaceBoard())
await pressEscape()
expect(panelState().workspaceBoardOpen).toBe(false)
})
it('lets Escape close the board when the board sheet itself is the open dialog', async () => {
await renderHookProbe()
const boardSheet = document.createElement('div')
boardSheet.setAttribute('role', 'dialog')
boardSheet.setAttribute('data-state', 'open')
boardSheet.setAttribute('data-workspace-board-sheet', '')
document.body.appendChild(boardSheet)
await updatePanel((state) => state.openWorkspaceBoard())
await pressEscape()
expect(panelState().workspaceBoardOpen).toBe(false)
})
it('keeps the board open on Escape while an interactive popover is open', async () => {
await renderHookProbe()
const popover = document.createElement('div')
popover.setAttribute('data-slot', 'popover-content')
popover.setAttribute('data-state', 'open')
document.body.appendChild(popover)
await updatePanel((state) => state.openWorkspaceBoard())
await pressEscape()
expect(panelState().workspaceBoardOpen).toBe(true)
})
it('keeps the board open on Escape while a nested dialog is open', async () => {
await renderHookProbe()
const dialog = document.createElement('div')
dialog.setAttribute('role', 'dialog')
dialog.setAttribute('data-state', 'open')
document.body.appendChild(dialog)
await updatePanel((state) => state.openWorkspaceBoard())
await pressEscape()
expect(panelState().workspaceBoardOpen).toBe(true)
})
})
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useAppStore } from '@/store'
const WORKSPACE_BOARD_ESCAPE_BLOCKING_OVERLAY_SELECTOR = [
'[data-slot="dropdown-menu-content"][data-state="open"]',
'[data-slot="context-menu-content"][data-state="open"]',
'[data-slot="popover-content"][data-state="open"]',
'[role="dialog"][data-state="open"]:not([data-workspace-board-sheet])',
'[role="alertdialog"][data-state="open"]',
'[role="menu"][data-state="open"]',
'[role="listbox"][data-state="open"]'
].join(', ')
export type WorkspaceBoardPanelState = {
workspaceBoardOpen: boolean
workspaceBoardMenuOpen: boolean
openWorkspaceBoard: () => void
closeWorkspaceBoard: () => void
toggleWorkspaceBoard: () => void
handleWorkspaceBoardOpenChange: (open: boolean) => void
setWorkspaceBoardMenuOpen: (open: boolean) => void
}
export function useWorkspaceBoardPanel(): WorkspaceBoardPanelState {
const [workspaceBoardOpen, setWorkspaceBoardOpen] = useState(false)
const [workspaceBoardMenuOpen, setWorkspaceBoardMenuOpen] = useState(false)
const workspaceBoardOpenRef = useRef(workspaceBoardOpen)
workspaceBoardOpenRef.current = workspaceBoardOpen
const openWorkspaceBoard = useCallback(() => {
if (workspaceBoardOpenRef.current) {
return
}
workspaceBoardOpenRef.current = true
// Why: opening the board is the user action; recording here avoids a
// post-render bookkeeping Effect in the drawer.
useAppStore.getState().recordFeatureInteraction('workspace-board')
setWorkspaceBoardOpen(true)
}, [])
const closeWorkspaceBoard = useCallback(() => {
workspaceBoardOpenRef.current = false
setWorkspaceBoardOpen(false)
setWorkspaceBoardMenuOpen(false)
}, [])
const handleWorkspaceBoardOpenChange = useCallback(
(open: boolean) => {
if (open) {
openWorkspaceBoard()
return
}
closeWorkspaceBoard()
},
[closeWorkspaceBoard, openWorkspaceBoard]
)
const toggleWorkspaceBoard = useCallback(() => {
if (workspaceBoardOpenRef.current) {
closeWorkspaceBoard()
return
}
openWorkspaceBoard()
}, [closeWorkspaceBoard, openWorkspaceBoard])
useEffect(() => {
if (!workspaceBoardOpen) {
return
}
const handleKeyDown = (event: KeyboardEvent): void => {
if (event.key !== 'Escape') {
return
}
if (workspaceBoardMenuOpen) {
return
}
// Why: Escape should dismiss interactive nested overlays before this
// companion panel, but non-interactive tooltips should not trap it.
if (document.querySelector(WORKSPACE_BOARD_ESCAPE_BLOCKING_OVERLAY_SELECTOR)) {
return
}
event.preventDefault()
closeWorkspaceBoard()
}
// Why: the workspace board is a non-modal companion panel, so focus may
// be outside the sheet when Escape should still dismiss it.
document.addEventListener('keydown', handleKeyDown, true)
return () => document.removeEventListener('keydown', handleKeyDown, true)
}, [closeWorkspaceBoard, workspaceBoardMenuOpen, workspaceBoardOpen])
return {
workspaceBoardOpen,
workspaceBoardMenuOpen,
openWorkspaceBoard,
closeWorkspaceBoard,
toggleWorkspaceBoard,
handleWorkspaceBoardOpenChange,
setWorkspaceBoardMenuOpen
}
}
+4
View File
@@ -3347,6 +3347,7 @@
"ee240a39eb": "Edit filters"
},
"SidebarHeader": {
"25a95899c9": "Add Project",
"92154beb7e": "New workspace",
"49f62c5665": "Workspace board",
"5c9c7c16aa": "Add a project to create workspaces",
@@ -3396,6 +3397,9 @@
"c4f8e1b72a": "X"
},
"SidebarToolbar": {
"87d0064026": "Workspace board moved to the bottom bar",
"a30e34eb5c": "Close workspace board",
"49f62c5665": "Workspace board",
"19e32d0e5f": "Open folder picker to add a project",
"abc62b6328": "Add Project"
},
+6 -2
View File
@@ -3330,7 +3330,8 @@
"49f62c5665": "tablero del espacio de trabajo",
"5c9c7c16aa": "Agregar un proyecto para crear espacios de trabajo",
"ca6f729da2": "Nuevo espacio de trabajo ({{value0}})",
"a30e34eb5c": "Cerrar tablero del espacio de trabajo"
"a30e34eb5c": "Cerrar tablero del espacio de trabajo",
"25a95899c9": "Agregar proyecto"
},
"SidebarNav": {
"80611a8b10": "Buscar",
@@ -3376,7 +3377,10 @@
},
"SidebarToolbar": {
"19e32d0e5f": "Abra el selector de carpetas para agregar un proyecto",
"abc62b6328": "Agregar proyecto"
"abc62b6328": "Agregar proyecto",
"87d0064026": "El tablero del espacio de trabajo se movió a la barra inferior",
"a30e34eb5c": "Cerrar tablero del espacio de trabajo",
"49f62c5665": "tablero del espacio de trabajo"
},
"SidebarWorkspaceFilterSection": {
"c3fa13dc2e": "Ocultar rama predeterminada",
+6 -2
View File
@@ -3332,7 +3332,8 @@
"49f62c5665": "ワークスペースボード",
"5c9c7c16aa": "プロジェクトを追加してワークスペースを作成する",
"ca6f729da2": "新規ワークスペース ({{value0}})",
"a30e34eb5c": "ワークスペースボードを閉じる"
"a30e34eb5c": "ワークスペースボードを閉じる",
"25a95899c9": "プロジェクトを追加"
},
"SidebarNav": {
"80611a8b10": "検索",
@@ -3378,7 +3379,10 @@
},
"SidebarToolbar": {
"19e32d0e5f": "フォルダーピッカーを開いてプロジェクトを追加します",
"abc62b6328": "プロジェクトを追加"
"abc62b6328": "プロジェクトを追加",
"87d0064026": "ワークスペースボードは下部バーに移動しました",
"a30e34eb5c": "ワークスペースボードを閉じる",
"49f62c5665": "ワークスペースボード"
},
"SidebarWorkspaceFilterSection": {
"c3fa13dc2e": "デフォルトのブランチを非表示にする",
+6 -2
View File
@@ -3332,7 +3332,8 @@
"49f62c5665": "워크스페이스 보드",
"5c9c7c16aa": "워크스페이스를 만들려면 프로젝트를 추가하세요.",
"ca6f729da2": "새 워크스페이스({{value0}})",
"a30e34eb5c": "워크스페이스 보드 닫기"
"a30e34eb5c": "워크스페이스 보드 닫기",
"25a95899c9": "프로젝트 추가"
},
"SidebarNav": {
"80611a8b10": "검색",
@@ -3378,7 +3379,10 @@
},
"SidebarToolbar": {
"19e32d0e5f": "폴더 선택기를 열어 프로젝트를 추가하세요.",
"abc62b6328": "프로젝트 추가"
"abc62b6328": "프로젝트 추가",
"87d0064026": "워크스페이스 보드가 하단 바로 옮겨졌습니다",
"a30e34eb5c": "워크스페이스 보드 닫기",
"49f62c5665": "워크스페이스 보드"
},
"SidebarWorkspaceFilterSection": {
"c3fa13dc2e": "기본 브랜치 숨기기",
+6 -2
View File
@@ -3332,7 +3332,8 @@
"49f62c5665": "工作区板",
"5c9c7c16aa": "添加项目以创建工作区",
"ca6f729da2": "新工作区 ({{value0}})",
"a30e34eb5c": "关闭工作区板"
"a30e34eb5c": "关闭工作区板",
"25a95899c9": "添加项目"
},
"SidebarNav": {
"80611a8b10": "搜索",
@@ -3378,7 +3379,10 @@
},
"SidebarToolbar": {
"19e32d0e5f": "打开文件夹选择器以添加项目",
"abc62b6328": "添加项目"
"abc62b6328": "添加项目",
"87d0064026": "工作区板已移到底部栏",
"a30e34eb5c": "关闭工作区板",
"49f62c5665": "工作区板"
},
"SidebarWorkspaceFilterSection": {
"c3fa13dc2e": "隐藏默认分支",
+3 -2
View File
@@ -1,4 +1,3 @@
import type { ITheme } from '@xterm/xterm'
import type { TerminalColorOverrides } from './types'
import { HEX_COLOR_RE } from './color-validation'
@@ -233,6 +232,8 @@ export function normalizeTerminalCustomThemes(value: unknown): TerminalCustomThe
return [...byId.values()].slice(-MAX_TERMINAL_CUSTOM_THEMES)
}
export function terminalCustomThemeToXtermTheme(theme: TerminalCustomTheme): ITheme {
export function terminalCustomThemeToXtermTheme(
theme: TerminalCustomTheme
): TerminalColorOverrides {
return { ...theme.terminal }
}