Skip to content
Merged
4 changes: 4 additions & 0 deletions docs/en/wework/workbench.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ When a runtime task is linked to a specific project-space task, select the task

Select a project name to expand or collapse its runtime tasks. Collapsed projects use a closed-folder icon; expanding a project changes the icon to an open folder so its state is easy to recognize.

## Split tasks by dragging

Drag a task from the sidebar to the top, bottom, left, or right target in the workbench to create a split in that direction. Dropping on the center target replaces the task in the current area. Once the pointer enters the workbench, sidebar sorting and automatic scrolling stop so they cannot take over the split operation. Move the pointer back into the sidebar to resume task reordering.

## Start a new task

The new-task page uses compact suggestion buttons to help choose a task direction. Selecting a direction reveals more specific prompts. Selecting a prompt writes it into the composer, where it can still be edited before sending.
Expand Down
4 changes: 4 additions & 0 deletions docs/zh/wework/workbench.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ Wework 桌面版使用顶部标签页承载任务、项目空间、智能体和

点击项目名称可以展开或收起该项目的运行任务。收起的项目显示闭合文件夹图标;展开后图标会改为打开的文件夹,方便识别当前展开状态。

## 拖拽任务分屏

从侧边栏拖拽任务到工作台中的上、下、左、右区域,可以在对应方向创建任务分屏;拖到中央区域则会替换当前区域的任务。指针进入工作台后,侧边栏会停止任务排序和自动滚动,避免侧边栏的拖拽效果抢占分屏操作。将指针移回侧边栏后,可以继续调整任务顺序。

## 开始新任务

新任务页使用紧凑的建议按钮帮助选择任务方向。点击一个方向后,可以继续选择更具体的提示;选中的提示会写入下方输入框,仍可在发送前编辑。
Expand Down
41 changes: 41 additions & 0 deletions wework/src/components/layout/DesktopSidebar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,10 @@ import {
import { WorkbenchContext } from '@/features/workbench/workbenchContexts'
import type { WorkbenchContextValue } from '@/features/workbench/workbenchContextTypes'
import type { ProjectSpaceApi } from '@/features/todo/projectSpaceSelection'
import {
dispatchWorkbenchSidebarPaneDragCancel,
dispatchWorkbenchSidebarPaneDragStart,
} from './workbenchPaneDrag'

const experimentalFeatures = vi.hoisted(() => ({ enabled: true }))

Expand Down Expand Up @@ -1434,6 +1438,43 @@ describe('DesktopSidebar', () => {
expect(pluginsButton.parentElement).not.toHaveClass('pt-2')
})

test('locks native sidebar scrolling while a task drag is outside the sidebar', () => {
renderSidebar()

const scrollContainer = screen.getByTestId('sidebar-worklists-scroll')
vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
top: 0,
left: 0,
right: 280,
bottom: 700,
width: 280,
height: 700,
toJSON: () => ({}),
} as DOMRect)
scrollContainer.scrollTop = 120

act(() => {
dispatchWorkbenchSidebarPaneDragStart({ paneKey: 'task:1', title: 'Task 1' })
fireEvent.pointerMove(window, { clientX: 600, clientY: 680 })
})

expect(scrollContainer).toHaveClass('overflow-y-hidden')
scrollContainer.scrollTop = 240
fireEvent.scroll(scrollContainer)
expect(scrollContainer.scrollTop).toBe(120)

act(() => {
fireEvent.pointerMove(window, { clientX: 120, clientY: 680 })
})
expect(scrollContainer).toHaveClass('overflow-y-auto')

act(() => {
dispatchWorkbenchSidebarPaneDragCancel()
})
})

test('matches Codex sidebar text emphasis levels', () => {
renderSidebar({ onToggleSidebar: vi.fn() }, { status: 'disconnected', isConnected: false })

Expand Down
60 changes: 58 additions & 2 deletions wework/src/components/layout/DesktopSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,10 @@ import type {
ArchiveRuntimeTaskResult,
} from '@/features/workbench/workbenchContextTypes'
import { getWorkbenchPaneKey } from './workbenchPaneIdentity'
import {
WORKBENCH_SIDEBAR_PANE_DRAG_END_EVENT,
WORKBENCH_SIDEBAR_PANE_DRAG_START_EVENT,
} from './workbenchPaneDrag'
import { DesktopSettingsMenu } from './DesktopSettingsMenu'
import { DesktopWindowControls } from './DesktopWindowControls'
import {
Expand Down Expand Up @@ -3108,6 +3112,11 @@ export function DesktopSidebar({
>(() => new Map())
const chatTaskPinRequestIdRef = useRef(0)
const [sidebarScrolled, setSidebarScrolled] = useState(false)
const sidebarWorklistsScrollRef = useRef<HTMLDivElement | null>(null)
const paneDragActiveRef = useRef(false)
const paneDragOutsideSidebarRef = useRef(false)
const paneDragLockedScrollTopRef = useRef(0)
const [paneDragOutsideSidebar, setPaneDragOutsideSidebar] = useState(false)
const visibleUnreadRuntimeTaskKeys = unreadRuntimeTaskKeys ?? EMPTY_RUNTIME_TASK_KEYS
const lifecycleSnapshot = useRuntimeTaskLifecycleStoreSnapshot()
const sidebarStateDeviceId = getLocalRuntimeStateDeviceId(devices)
Expand Down Expand Up @@ -3690,6 +3699,45 @@ export function DesktopSidebar({
displayedProjectsExpanded,
])

useEffect(() => {
const setOutsideSidebar = (outside: boolean) => {
if (paneDragOutsideSidebarRef.current === outside) return
paneDragOutsideSidebarRef.current = outside
setPaneDragOutsideSidebar(outside)
}
const handlePaneDragStart = () => {
paneDragActiveRef.current = true
paneDragLockedScrollTopRef.current = sidebarWorklistsScrollRef.current?.scrollTop ?? 0
}
const handlePointerMove = (event: globalThis.PointerEvent) => {
if (!paneDragActiveRef.current) return
const scrollContainer = sidebarWorklistsScrollRef.current
if (!scrollContainer) return
const bounds = scrollContainer.getBoundingClientRect()
const outside = event.clientX < bounds.left || event.clientX > bounds.right
if (outside && !paneDragOutsideSidebarRef.current) {
paneDragLockedScrollTopRef.current = scrollContainer.scrollTop
}
setOutsideSidebar(outside)
if (outside && scrollContainer.scrollTop !== paneDragLockedScrollTopRef.current) {
scrollContainer.scrollTop = paneDragLockedScrollTopRef.current
}
}
const handlePaneDragEnd = () => {
paneDragActiveRef.current = false
setOutsideSidebar(false)
}

window.addEventListener(WORKBENCH_SIDEBAR_PANE_DRAG_START_EVENT, handlePaneDragStart)
window.addEventListener(WORKBENCH_SIDEBAR_PANE_DRAG_END_EVENT, handlePaneDragEnd)
window.addEventListener('pointermove', handlePointerMove, true)
return () => {
window.removeEventListener(WORKBENCH_SIDEBAR_PANE_DRAG_START_EVENT, handlePaneDragStart)
window.removeEventListener(WORKBENCH_SIDEBAR_PANE_DRAG_END_EVENT, handlePaneDragEnd)
window.removeEventListener('pointermove', handlePointerMove, true)
}
}, [])

return (
<aside
data-testid={containerTestId}
Expand Down Expand Up @@ -3812,11 +3860,19 @@ export function DesktopSidebar({
</nav>

<div
ref={sidebarWorklistsScrollRef}
data-testid="sidebar-worklists-scroll"
data-scrolled={sidebarScrolled}
onScroll={event => setSidebarScrolled(event.currentTarget.scrollTop > 0)}
onScroll={event => {
if (paneDragOutsideSidebarRef.current) {
event.currentTarget.scrollTop = paneDragLockedScrollTopRef.current
return
}
setSidebarScrolled(event.currentTarget.scrollTop > 0)
}}
className={cn(
'relative mb-2 mt-0.5 min-h-0 flex-1 overflow-y-auto border-t border-transparent pb-3 [overflow-anchor:none] [mask-image:linear-gradient(to_bottom,black_0,black_calc(100%_-_16px),transparent_100%)]',
'relative mb-2 mt-0.5 min-h-0 flex-1 border-t border-transparent pb-3 [overflow-anchor:none] [mask-image:linear-gradient(to_bottom,black_0,black_calc(100%_-_16px),transparent_100%)]',
paneDragOutsideSidebar ? 'overflow-y-hidden' : 'overflow-y-auto',
sidebarScrolled &&
'scrollbar-soft border-border [mask-image:linear-gradient(to_bottom,transparent_0,black_12px,black_calc(100%_-_16px),transparent_100%)]',
!sidebarScrolled && 'scrollbar-none'
Expand Down
161 changes: 161 additions & 0 deletions wework/src/components/layout/SidebarSortableList.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import {
WORKBENCH_SIDEBAR_PANE_DRAG_END_EVENT,
type WorkbenchSidebarPaneDragEndData,
} from './workbenchPaneDrag'
import { getSidebarAutoScrollConfiguration } from './sidebarSortableAutoScroll'
import { SidebarSortableList } from './SidebarSortableList'

interface Item {
id: string
label: string
}

const items: Item[] = [
{ id: 'first', label: 'First' },
{ id: 'second', label: 'Second' },
]

function mockRect(element: HTMLElement, top: number) {
vi.spyOn(element, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: top,
top,
left: 0,
right: 240,
bottom: top + 30,
width: 240,
height: 30,
toJSON: () => ({}),
} as DOMRect)
}

async function waitForPointerSensorCleanup() {
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 60))
})
}

afterEach(() => {
vi.restoreAllMocks()
})

describe('SidebarSortableList', () => {
it('disables both dnd-kit scroll paths for task drags that can leave the sidebar', () => {
expect(getSidebarAutoScrollConfiguration(true)).toEqual({
enabled: false,
layoutShiftCompensation: false,
})
expect(getSidebarAutoScrollConfiguration(false)).toEqual({ enabled: true })
})

it('stops sidebar sorting and reports the real pointer position outside the sidebar', async () => {
const onMove = vi.fn().mockResolvedValue(undefined)
const dragEndDetails: WorkbenchSidebarPaneDragEndData[] = []
const handleExternalDragEnd = (event: Event) => {
const detail = (event as CustomEvent<WorkbenchSidebarPaneDragEndData | null>).detail
if (detail) dragEndDetails.push(detail)
}
window.addEventListener(WORKBENCH_SIDEBAR_PANE_DRAG_END_EVENT, handleExternalDragEnd)

render(
<div data-testid="scroll-container" className="overflow-y-auto">
<SidebarSortableList
items={items}
getId={item => item.id}
getLabel={item => item.label}
getExternalDragData={item => ({ paneKey: `pane:${item.id}`, title: item.label })}
onMove={onMove}
testId="sortable-list"
renderItem={item => (
<div data-testid={`item-${item.id}`}>
<span data-testid={`activator-${item.id}`} data-sidebar-drag-activator>
{item.label}
</span>
</div>
)}
/>
</div>
)

const scrollContainer = screen.getByTestId('scroll-container')
const firstSortable = screen.getByTestId('item-first').parentElement as HTMLElement
const secondSortable = screen.getByTestId('item-second').parentElement as HTMLElement
const scrollBy = vi.fn()
Object.defineProperties(scrollContainer, {
clientHeight: { configurable: true, value: 60 },
clientWidth: { configurable: true, value: 240 },
scrollHeight: { configurable: true, value: 200 },
scrollWidth: { configurable: true, value: 240 },
scrollBy: { configurable: true, value: scrollBy },
})
vi.spyOn(scrollContainer, 'getBoundingClientRect').mockReturnValue({
x: 0,
y: 0,
top: 0,
left: 0,
right: 240,
bottom: 60,
width: 240,
height: 60,
toJSON: () => ({}),
} as DOMRect)
mockRect(firstSortable, 0)
mockRect(secondSortable, 30)

fireEvent.pointerDown(screen.getByTestId('activator-first'), {
button: 0,
buttons: 1,
clientX: 12,
clientY: 15,
isPrimary: true,
pointerId: 1,
})
fireEvent.pointerMove(document, {
buttons: 1,
clientX: 12,
clientY: 45,
isPrimary: true,
pointerId: 1,
})
expect(firstSortable).toHaveAttribute('data-dragging', 'true')

fireEvent.pointerMove(document, {
buttons: 1,
clientX: 640,
clientY: 59,
isPrimary: true,
pointerId: 1,
})

await waitFor(() => {
expect(firstSortable.style.transform).toBe('')
expect(secondSortable.style.transform).toBe('')
})
await act(async () => {
await new Promise(resolve => setTimeout(resolve, 30))
})
expect(scrollBy).not.toHaveBeenCalled()

fireEvent.pointerUp(document, {
button: 0,
buttons: 0,
clientX: 640,
clientY: 59,
isPrimary: true,
pointerId: 1,
})

await waitFor(() => expect(dragEndDetails).toHaveLength(1))
expect(dragEndDetails[0]).toMatchObject({
paneKey: 'pane:first',
clientX: 640,
clientY: 59,
})
expect(onMove).not.toHaveBeenCalled()

window.removeEventListener(WORKBENCH_SIDEBAR_PANE_DRAG_END_EVENT, handleExternalDragEnd)
await waitForPointerSensorCleanup()
})
})
Loading
Loading