diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 3885f98290..92cfdfc18a 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -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 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index d017e983d7..1a550129a8 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -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 diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 18d7db22b3..9b5816d131 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -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) 在拖拽结束、替换预览或行卸载时移除副本。预览不改变原行的背景或拖放事件处理。如果浏览器拒绝自定义拖拽图片,则移除副本并继续原生拖动。 ### 悬浮卡片 diff --git a/packages/client/ui-workspace/src/client/rows/Rows.module.css b/packages/client/ui-workspace/src/client/rows/Rows.module.css index 51cbc2669a..4f291e73bb 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.module.css +++ b/packages/client/ui-workspace/src/client/rows/Rows.module.css @@ -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; diff --git a/packages/client/ui-workspace/src/client/rows/Rows.tsx b/packages/client/ui-workspace/src/client/rows/Rows.tsx index fcf9bc02d8..458a32b113 100644 --- a/packages/client/ui-workspace/src/client/rows/Rows.tsx +++ b/packages/client/ui-workspace/src/client/rows/Rows.tsx @@ -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() }} > {row.expanded ? : } @@ -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) => { diff --git a/packages/client/ui-workspace/src/client/rows/useRowDragPreview.ts b/packages/client/ui-workspace/src/client/rows/useRowDragPreview.ts index 06b5fa0a0d..bf84a9e1f4 100644 --- a/packages/client/ui-workspace/src/client/rows/useRowDragPreview.ts +++ b/packages/client/ui-workspace/src/client/rows/useRowDragPreview.ts @@ -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) => void - clear: () => void + clear: () => boolean } { const current = useRef(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): 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( + '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 } diff --git a/packages/client/ui-workspace/tests/rows.client.spec.tsx b/packages/client/ui-workspace/tests/rows.client.spec.tsx index cf1c54c0cc..336acef9cb 100644 --- a/packages/client/ui-workspace/tests/rows.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rows.client.spec.tsx @@ -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('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() + 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() + 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,