🐛 fix(client): tie drag completion to the source row

This commit is contained in:
Saafo committed 2026-10-20 18:05:30 +08:00
1 parent eb8ef617c0
commit 4470ca8910
7 files changed
+82 -17

No files matched your search

@@ -51,8 +51,8 @@
en: ad4a333892f2fe92
zh: 3a670fd401f4d7a7
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/view-state:
en: 9234b8ecdff3cb1e
zh: 416f584065690b76
en: faedf74d08113970
zh: c5f383727a65a1b1
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation/hover-cards:
en: 8bcb7bd90d7c5773
zh: 17663be926a099d2
+1 -1
View File
@@ -190,7 +190,7 @@ The sidebar hides durable summaries with `origin: 'subagent'`. A visible ordinar
Row motion belongs to [AnimatedRows](src/client/rows/AnimatedRows.tsx). It measures keyed rows before and after React commits that change their membership or order, and uses native movement and opacity animations. Removed rows fade as inert copies outside the scrolling list; they do not delay React unmounting or extend its scroll range. Initial loading, drag commits, overflow expansion, and view-option changes settle immediately. The animator has no layout observers or polling and does not measure content-only updates or scrolling.
Native Workspace and Session drags use an inert offscreen copy for their preview, with the row's dimensions and indentation over the themed sidebar fill. [useRowDragPreview](src/client/rows/useRowDragPreview.ts) removes the copy on drag end, replacement, or row unmount. Source rows retain their normal backgrounds and drag-and-drop handlers.
Native Workspace and Session drags use an inert offscreen copy for their preview, with the row's dimensions and indentation over the themed sidebar fill. [useRowDragPreview](src/client/rows/useRowDragPreview.ts) removes the copy on drag end, replacement, or row unmount. The preview does not change the source row's background or drag-and-drop handlers. If the browser rejects a custom drag image, the copy is removed and native dragging continues.
### Hover cards
+1 -1
View File
@@ -190,7 +190,7 @@ Workspace 基线就绪后,浏览器持久化的展开状态和 Session 顺序
行动画由 [AnimatedRows](src/client/rows/AnimatedRows.tsx) 负责。它仅在 React 提交改变行成员或顺序时读取更新前后的位置,并使用浏览器原生位移与透明度动画。被移除的行以不可交互的副本在滚动列表外淡出,不会延迟 React 卸载,也不会扩大列表的滚动范围。初始加载、拖拽提交、展开其余会话和视图选项变化直接完成。动画组件不使用布局观察器或轮询,也不会因仅内容更新或滚动而测量位置。
Workspace 和 Session 的原生拖拽预览使用不可交互的离屏副本,在主题侧栏底色上保留行尺寸和缩进。[useRowDragPreview](src/client/rows/useRowDragPreview.ts) 在拖拽结束、替换预览或行卸载时移除副本。原行保留正常背景和拖放事件处理。
Workspace 和 Session 的原生拖拽预览使用不可交互的离屏副本,在主题侧栏底色上保留行尺寸和缩进。[useRowDragPreview](src/client/rows/useRowDragPreview.ts) 在拖拽结束、替换预览或行卸载时移除副本。预览不改变原行的背景或拖放事件处理。如果浏览器拒绝自定义拖拽图片,则移除副本并继续原生拖动。
### 悬浮卡片
@@ -28,6 +28,13 @@
pointer-events: none;
}
.dragPreview > :first-child {
box-sizing: border-box;
margin: 0;
width: 100%;
height: 100%;
}
.searchResultRow {
display: flex;
flex-direction: column;
@@ -255,10 +255,10 @@ export function ProjectRowItem({ group, containsCurrentDescendant = false, onTog
: (e) => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', row.key)
dragPreview.start(e)
drag.start()
dragPreview.start(e)
}}
onDragEnd={() => { dragPreview.clear(); drag?.end() }}
onDragEnd={() => { if (dragPreview.clear()) drag?.end() }}
>
<span className={clsx(css.slot, css.folder, active && css.folderActive)}>
{row.expanded ? <IconFolderOpenRegular /> : <IconFolderCloseRegular />}
@@ -626,10 +626,10 @@ export function SessionNodeItem({
: (e) => {
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData('text/plain', node.id)
dragPreview.start(e)
drag.start()
dragPreview.start(e)
}}
onDragEnd={() => { dragPreview.clear(); drag?.end() }}
onDragEnd={() => { if (dragPreview.clear()) drag?.end() }}
onDragOver={drag === undefined
? undefined
: (e) => {
@@ -6,31 +6,40 @@ import css from './Rows.module.css'
/**
* Own a row's inert drag preview until drag end, replacement, or unmount.
* The offscreen copy keeps WebKit snapshots separate from overlapping panels.
* @returns preview creation and cleanup callbacks for native drag handlers.
* Its cloned slot DOM remains in document.body for that lifetime; query live rows
* within their list to exclude duplicate text and element IDs.
* @returns preview callbacks; cleanup reports whether this row started a drag,
* even when its preview failed or the row became non-draggable before drag end.
*/
export function useRowDragPreview(): {
start: (event: DragEvent<HTMLElement>) => void
clear: () => void
clear: () => boolean
} {
const current = useRef<HTMLDivElement | null>(null)
const dragging = useRef(false)
const preview = useMemo(() => {
const clear = (): void => {
const clear = (): boolean => {
const wasDragging = dragging.current
dragging.current = false
current.current?.remove()
current.current = null
return wasDragging
}
return {
clear,
start: (event: DragEvent<HTMLElement>): void => {
clear()
dragging.current = true
const row = event.currentTarget
const rect = row.getBoundingClientRect()
const style = getComputedStyle(row)
const copy = row.cloneNode(true) as HTMLElement
copy.removeAttribute('data-row-key')
Object.assign(copy.style, {
boxSizing: 'border-box', margin: '0', width: '100%', height: '100%',
color: style.color,
})
copy.tabIndex = -1
for (const control of copy.querySelectorAll<HTMLElement>(
'button, a[href], input, select, textarea, [tabindex], [contenteditable]',
)) control.tabIndex = -1
copy.style.color = style.color
const image = row.ownerDocument.createElement('div')
image.className = css.dragPreview as string
image.inert = true
@@ -43,10 +52,16 @@ export function useRowDragPreview(): {
image.append(copy)
row.ownerDocument.body.append(image)
current.current = image
event.dataTransfer.setDragImage(image, event.clientX - rect.left, event.clientY - rect.top)
try {
event.dataTransfer.setDragImage(image, event.clientX - rect.left, event.clientY - rect.top)
} catch (_error) {
// Browsers may reject a custom image; retain native dragging without the copy.
image.remove()
current.current = null
}
},
}
}, [])
useEffect(() => preview.clear, [preview])
useEffect(() => () => { preview.clear() }, [preview])
return preview
}
@@ -167,6 +167,8 @@ describe('native row drag previews', () => {
it.each(['workspace', 'session'] as const)('uses an inert offscreen %s copy without changing the source row', (kind) => {
const { row, drag, transfer, start } = fixture(kind)
const originalStyle = row.getAttribute('style')
fireEvent.dragEnd(row)
expect(drag.end).not.toHaveBeenCalled()
const image = start()
expect(transfer.setDragImage).toHaveBeenCalledWith(image, 65, 12)
expect(transfer.effectAllowed).toBe('move')
@@ -175,6 +177,9 @@ describe('native row drag previews', () => {
expect(image.parentElement).toBe(document.body)
expect(image.inert).toBe(true)
expect(image.getAttribute('aria-hidden')).toBe('true')
const controls = image.querySelectorAll<HTMLElement>('button, a[href], input, select, textarea, [tabindex]')
expect(controls.length).toBeGreaterThan(0)
expect([...controls].every(control => control.tabIndex === -1)).toBe(true)
expect(image.style.left).toBe('-200px')
expect(image.style.width).toBe('200px')
expect(image.style.height).toBe('34px')
@@ -187,6 +192,31 @@ describe('native row drag previews', () => {
expect(row.getAttribute('draggable')).toBe('true')
})
it.each(['workspace', 'session'] as const)('keeps %s dragging active when the browser rejects its preview', (kind) => {
const { drag, transfer, start } = fixture(kind)
transfer.setDragImage.mockImplementation(() => { throw new DOMException('Image unavailable') })
const image = start()
expect(drag.start).toHaveBeenCalledOnce()
expect(vi.mocked(drag.start).mock.invocationCallOrder[0])
.toBeLessThan(transfer.setDragImage.mock.invocationCallOrder[0]!)
expect(image.isConnected).toBe(false)
fireEvent.dragEnd(screen.getByRole('treeitem'))
expect(drag.end).toHaveBeenCalledOnce()
})
it('finishes a Session drag when the source becomes archived before drag end', () => {
const { row, drag, start, rerender } = fixture('session')
const image = start()
rerender(<SessionNodeItem node={{
id: sid('session'), title: 'Draggable row', blank: false, running: false,
runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: true,
}} currentId={undefined} now={0} onOpen={vi.fn()} drag={drag} t={t} />)
expect(row.getAttribute('draggable')).toBe('false')
fireEvent.dragEnd(row)
expect(image.isConnected).toBe(false)
expect(drag.end).toHaveBeenCalledOnce()
})
it.each(['workspace', 'session'] as const)('removes %s previews on replacement, drag end, and unmount', (kind) => {
const { row, drag, start, unmount } = fixture(kind)
const first = start()
@@ -1164,6 +1194,19 @@ describe('workspace browser rows', () => {
expect(screen.queryByRole('img', { name: '已置顶' })).toBeNull()
})
it.each(['blank', 'archived'] as const)('ignores child drag end on a %s Session row', (state) => {
const drag = dragProps({ active: true, marker: 'before' })
render(<SessionNodeItem node={{
id: sid('not-draggable'), title: 'Not draggable', blank: state === 'blank', running: false,
runningSubagentCount: 0, completed: false, updatedAt: 0, pinned: false, archived: state === 'archived',
}} currentId={undefined} now={0} onOpen={vi.fn()} drag={drag} t={t} />)
const row = screen.getByRole('treeitem')
expect(row.getAttribute('draggable')).toBe('false')
fireEvent.dragEnd(row.firstElementChild!)
fireEvent.dragEnd(row)
expect(drag.end).not.toHaveBeenCalled()
})
it('marks an archived row and blocks dragging it', () => {
const node: SessionNode = {
id: sid('s1'), title: 'Stored', blank: false, running: false,