mirror of
https://github.com/stablyai/orca.git
synced 2026-10-16 00:03:09 +00:00
Move workspace board to sidebar bottom toolbar (#5146)
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
committed by
Jinwoo-H
co-authored by
Orca
parent
f93549fbf1
commit
787c42f92a
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "デフォルトのブランチを非表示にする",
|
||||
|
||||
@@ -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": "기본 브랜치 숨기기",
|
||||
|
||||
@@ -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": "隐藏默认分支",
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user