diff --git a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.module.css b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.module.css index ab6b2e9f736..ef508803fc4 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.module.css +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.module.css @@ -451,11 +451,13 @@ tr:hover .selectedCell { background: var(--card); } +.activeHeaderCell.frozenHeaderCell { + background: color-mix(in srgb, var(--agent-blue-500) 8%, var(--card)); +} + .evenRow .stickyActionCell, .evenRow .frozenCell { - background: - linear-gradient(var(--subtle-bg), var(--subtle-bg)), - var(--card); + background: linear-gradient(var(--subtle-bg), var(--subtle-bg)), var(--card); } tr:hover .stickyActionCell, diff --git a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx index d0bedaa7e7f..c1ce5b98fa6 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.test.tsx @@ -10,6 +10,10 @@ import { EnhancedMarkdownTable } from './EnhancedMarkdownTable'; ).IS_REACT_ACT_ENVIRONMENT = true; const mounted: Array<{ root: Root; container: HTMLElement }> = []; +const originalDocumentHidden = Object.getOwnPropertyDescriptor( + document, + 'hidden', +); const originalElementFromPoint = document.elementFromPoint; const COLUMN_DRAG_MIME = 'application/x-qwen-web-shell-table-column'; @@ -21,6 +25,11 @@ afterEach(() => { vi.restoreAllMocks(); vi.useRealTimers(); document.getSelection()?.removeAllRanges(); + if (originalDocumentHidden) { + Object.defineProperty(document, 'hidden', originalDocumentHidden); + } else { + Reflect.deleteProperty(document, 'hidden'); + } if (originalElementFromPoint) { Object.defineProperty(document, 'elementFromPoint', { configurable: true, @@ -866,6 +875,31 @@ describe('EnhancedMarkdownTable', () => { ); }); + it('flushes pending resize width when the window blurs', () => { + const container = renderTable(); + const resize = button(container, 'Resize Team'); + + act(() => { + resize.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + button: 0, + clientX: 100, + }), + ); + }); + act(() => { + window.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 200 }), + ); + window.dispatchEvent(new Event('blur')); + }); + + expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe( + '260px', + ); + }); + it('stops resizing a column when page visibility changes', () => { const container = renderTable(); const resize = button(container, 'Resize Team'); @@ -879,6 +913,10 @@ describe('EnhancedMarkdownTable', () => { }), ); }); + Object.defineProperty(document, 'hidden', { + configurable: true, + value: true, + }); act(() => { document.dispatchEvent(new Event('visibilitychange')); }); @@ -893,6 +931,38 @@ describe('EnhancedMarkdownTable', () => { ); }); + it('keeps resizing when page visibility changes while visible', () => { + const container = renderTable(); + const resize = button(container, 'Resize Team'); + + act(() => { + resize.dispatchEvent( + new MouseEvent('mousedown', { + bubbles: true, + button: 0, + clientX: 100, + }), + ); + }); + Object.defineProperty(document, 'hidden', { + configurable: true, + value: false, + }); + act(() => { + document.dispatchEvent(new Event('visibilitychange')); + }); + act(() => { + window.dispatchEvent( + new MouseEvent('mousemove', { bubbles: true, clientX: 220 }), + ); + window.dispatchEvent(new MouseEvent('mouseup', { bubbles: true })); + }); + + expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe( + '280px', + ); + }); + it('resizes a column with keyboard arrows', () => { const container = renderTable(); const resize = button(container, 'Resize Team'); @@ -924,6 +994,25 @@ describe('EnhancedMarkdownTable', () => { ); }); + it('ignores keyboard resize arrows with modifiers', () => { + const container = renderTable(); + const resize = button(container, 'Resize Team'); + + act(() => { + resize.dispatchEvent( + new KeyboardEvent('keydown', { + bubbles: true, + ctrlKey: true, + key: 'ArrowRight', + }), + ); + }); + + expect(button(container, 'Sort by Team').closest('th')?.style.width).toBe( + '160px', + ); + }); + it('shows column move handles only for the active column', () => { const container = renderWideTable(); const teamHandle = button(container, 'Move Team'); @@ -945,6 +1034,66 @@ describe('EnhancedMarkdownTable', () => { ).toContain('activeHeaderCell'); }); + it('clears the active column move handle on outside click, cell selection, and Escape', () => { + const container = renderWideTable(); + const teamHandle = button(container, 'Move Team'); + + click(button(container, 'Sort by Team')); + act(() => { + document.body.dispatchEvent( + new MouseEvent('mousedown', { bubbles: true }), + ); + }); + expect(teamHandle.className).not.toContain('reorderHandleVisible'); + expect(teamHandle.tabIndex).toBe(-1); + + click(button(container, 'Sort by Team, ascending')); + act(() => { + dataCell(container, 0, 0).dispatchEvent( + new MouseEvent('mousedown', { bubbles: true, button: 0 }), + ); + }); + expect(teamHandle.className).not.toContain('reorderHandleVisible'); + expect(teamHandle.tabIndex).toBe(-1); + + click(button(container, 'Sort by Team, descending')); + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }), + ); + }); + expect(teamHandle.className).not.toContain('reorderHandleVisible'); + expect(teamHandle.tabIndex).toBe(-1); + }); + + it('keeps the active column when Escape closes an open filter menu first', () => { + const container = renderWideTable(); + const teamHandle = button(container, 'Move Team'); + + click(button(container, 'Sort by Team')); + click(button(container, 'Filter Team')); + expect(container.textContent).toContain('Custom filter'); + + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }), + ); + }); + + expect(container.textContent).not.toContain('Custom filter'); + expect(teamHandle.className).toContain('reorderHandleVisible'); + expect(teamHandle.tabIndex).toBe(0); + + act(() => { + document.dispatchEvent( + new KeyboardEvent('keydown', { bubbles: true, key: 'Escape' }), + ); + }); + + expect(teamHandle.className).not.toContain('reorderHandleVisible'); + expect(teamHandle.tabIndex).toBe(-1); + }); + it('reorders columns and quick copies in the visible order', () => { const writeText = mockClipboard(); const container = renderWideTable(); @@ -978,7 +1127,10 @@ describe('EnhancedMarkdownTable', () => { const source = renderWideTable(); const target = renderWideTable(); - dragColumnElements(button(source, 'Move Score'), button(target, 'Move Team')); + dragColumnElements( + button(source, 'Move Score'), + button(target, 'Move Team'), + ); expect(rowTexts(source)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']); expect(rowTexts(target)).toEqual(['Alpha|US|10', 'Beta|EMEA|2']); @@ -1033,6 +1185,14 @@ describe('EnhancedMarkdownTable', () => { ).toContain('frozenHeaderCell'); expect(dataCell(container, 0, 0).className).toContain('frozenCell'); + click(button(container, 'Sort by Team')); + expect( + button(container, 'Sort by Team, ascending').closest('th')?.className, + ).toContain('activeHeaderCell'); + expect( + button(container, 'Sort by Team, ascending').closest('th')?.className, + ).toContain('frozenHeaderCell'); + dragColumn(container, 'Move Score', 'Move Team'); expect( button(container, 'Sort by Score').closest('th')?.className, diff --git a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx index 84beb673aaa..265e83840f1 100644 --- a/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx +++ b/packages/web-shell/client/components/messages/EnhancedMarkdownTable.tsx @@ -329,6 +329,24 @@ function sanitizeForClipboard(value: string): string { return /^[=+\-@]/.test(inspectedValue) ? `'${value}` : value; } +function applyColumnWidth( + current: Record, + columnIndex: number, + nextWidth: number, +): Record { + const currentWidth = current[columnIndex] ?? DEFAULT_COLUMN_WIDTH; + if (currentWidth === nextWidth) return current; + if (nextWidth === DEFAULT_COLUMN_WIDTH) { + const next = { ...current }; + delete next[columnIndex]; + return next; + } + return { + ...current, + [columnIndex]: nextWidth, + }; +} + function getSelectionText( range: SelectionRange | null, rows: EnhancedTableRow[], @@ -412,11 +430,7 @@ function moveVisibleColumn( const visibleColumns = order.filter( (columnIndex) => !hiddenColumns.has(columnIndex), ); - const nextVisibleColumns = moveColumn( - visibleColumns, - fromColumn, - toColumn, - ); + const nextVisibleColumns = moveColumn(visibleColumns, fromColumn, toColumn); if (nextVisibleColumns === visibleColumns) return order; let visibleIndex = 0; return order.map((columnIndex) => { @@ -1181,6 +1195,8 @@ export function EnhancedTable({ columnIndex: number; } | null>(null); const selectionFrameRef = useRef(0); + const resizeFrameRef = useRef(0); + const pendingResizeWidthRef = useRef(null); const draggingColumnRef = useRef(null); const tableStructureKey = useMemo( () => @@ -1328,6 +1344,7 @@ export function EnhancedTable({ }; const handleMenuKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { + event.preventDefault(); closeFilterMenu(); return; } @@ -1377,6 +1394,28 @@ export function EnhancedTable({ }; }, [closeFilterMenu, openFilterMenu]); + useEffect(() => { + const clearActiveColumnOnOutsideMouseDown = (event: MouseEvent) => { + const target = event.target; + if (target instanceof Node && !shellRef.current?.contains(target)) { + setActiveColumn(null); + } + }; + const clearActiveColumnOnEscape = (event: KeyboardEvent) => { + if (event.defaultPrevented || openFilterMenu) return; + if (event.key === 'Escape') setActiveColumn(null); + }; + document.addEventListener('mousedown', clearActiveColumnOnOutsideMouseDown); + document.addEventListener('keydown', clearActiveColumnOnEscape); + return () => { + document.removeEventListener( + 'mousedown', + clearActiveColumnOnOutsideMouseDown, + ); + document.removeEventListener('keydown', clearActiveColumnOnEscape); + }; + }, [openFilterMenu]); + const filteredRows = useMemo( () => applyFilters(table.rows, filters), [filters, table.rows], @@ -1418,35 +1457,46 @@ export function EnhancedTable({ useEffect(() => { if (!resizingColumn) return; + const applyPendingResize = () => { + const nextWidth = pendingResizeWidthRef.current; + pendingResizeWidthRef.current = null; + if (resizeFrameRef.current) { + cancelAnimationFrame(resizeFrameRef.current); + resizeFrameRef.current = 0; + } + if (nextWidth === null) return; + setColumnWidths((current) => + applyColumnWidth(current, resizingColumn.columnIndex, nextWidth), + ); + }; const resizeColumn = (event: MouseEvent) => { - const nextWidth = clampColumnWidth( + pendingResizeWidthRef.current = clampColumnWidth( resizingColumn.startWidth + event.clientX - resizingColumn.startX, ); - setColumnWidths((current) => { - const currentWidth = - current[resizingColumn.columnIndex] ?? DEFAULT_COLUMN_WIDTH; - if (currentWidth === nextWidth) return current; - if (nextWidth === DEFAULT_COLUMN_WIDTH) { - const next = { ...current }; - delete next[resizingColumn.columnIndex]; - return next; - } - return { - ...current, - [resizingColumn.columnIndex]: nextWidth, - }; - }); + if (resizeFrameRef.current) return; + resizeFrameRef.current = requestAnimationFrame(applyPendingResize); + }; + const stopResize = () => { + applyPendingResize(); + setResizingColumn(null); + }; + const stopResizeWhenHidden = () => { + if (document.hidden) stopResize(); }; - const stopResize = () => setResizingColumn(null); window.addEventListener('mousemove', resizeColumn); window.addEventListener('mouseup', stopResize); window.addEventListener('blur', stopResize); - document.addEventListener('visibilitychange', stopResize); + document.addEventListener('visibilitychange', stopResizeWhenHidden); return () => { window.removeEventListener('mousemove', resizeColumn); window.removeEventListener('mouseup', stopResize); window.removeEventListener('blur', stopResize); - document.removeEventListener('visibilitychange', stopResize); + document.removeEventListener('visibilitychange', stopResizeWhenHidden); + if (resizeFrameRef.current) { + cancelAnimationFrame(resizeFrameRef.current); + resizeFrameRef.current = 0; + } + pendingResizeWidthRef.current = null; }; }, [resizingColumn]); @@ -1615,6 +1665,9 @@ export function EnhancedTable({ event: ReactKeyboardEvent, columnIndex: number, ) => { + if (event.ctrlKey || event.metaKey || event.altKey || event.shiftKey) + return; + let delta = 0; if (event.key === 'ArrowRight') { delta = KEYBOARD_COLUMN_RESIZE_STEP; @@ -1629,16 +1682,7 @@ export function EnhancedTable({ setColumnWidths((current) => { const width = current[columnIndex] ?? DEFAULT_COLUMN_WIDTH; const nextWidth = clampColumnWidth(width + delta); - if (width === nextWidth) return current; - if (nextWidth === DEFAULT_COLUMN_WIDTH) { - const next = { ...current }; - delete next[columnIndex]; - return next; - } - return { - ...current, - [columnIndex]: nextWidth, - }; + return applyColumnWidth(current, columnIndex, nextWidth); }); }; @@ -1674,10 +1718,7 @@ export function EnhancedTable({ ) => { const sourceColumnIndex = draggingColumnRef.current; stopColumnDrag(); - if ( - sourceColumnIndex === null || - !hasColumnDragData(event.dataTransfer) - ) { + if (sourceColumnIndex === null || !hasColumnDragData(event.dataTransfer)) { return; } event.preventDefault(); @@ -1698,6 +1739,7 @@ export function EnhancedTable({ if (openFilterMenu !== null) { setOpenFilterMenu(null); } + setActiveColumn(null); draggingRef.current = true; setSelection({ anchorRow: rowIndex,