diff --git a/web/oss/src/assets/custom-resize-handle.css b/web/oss/src/assets/custom-resize-handle.css index 2b46924618..baa41f815e 100644 --- a/web/oss/src/assets/custom-resize-handle.css +++ b/web/oss/src/assets/custom-resize-handle.css @@ -28,3 +28,13 @@ th:hover .custom-resize-handle, th:focus .custom-resize-handle { opacity: 1; } + +/* + * Fixed-right columns (e.g. a sticky Actions column) anchor the handle inside + * the cell so the default 9px overhang doesn't bleed into the scrollbar gutter + * and create a phantom column slot on the right edge of the table. + */ +.ant-table-cell-fix-right .custom-resize-handle { + right: 0; + width: 9px; +} diff --git a/web/oss/src/components/pages/observability/components/NodeNameCell.tsx b/web/oss/src/components/pages/observability/components/NodeNameCell.tsx index 879433716e..fe42d998a0 100644 --- a/web/oss/src/components/pages/observability/components/NodeNameCell.tsx +++ b/web/oss/src/components/pages/observability/components/NodeNameCell.tsx @@ -1,10 +1,8 @@ import {memo} from "react" -import {Space, Tooltip, Typography} from "antd" -import {useAtomValue} from "jotai" +import {Typography} from "antd" import {SpanCategory} from "@/oss/services/tracing/types" -import {nodeDisplayNameAtomFamily} from "@/oss/state/newObservability" import {spanTypeStyles} from "../assets/constants" @@ -14,24 +12,17 @@ interface Props { } const NodeNameCell = memo(({name, type}: Props) => { - const display = useAtomValue(nodeDisplayNameAtomFamily(name)) const {icon: Icon} = spanTypeStyles[type ?? "undefined"] return ( - -
+
+
- - {display.truncated ? ( - - {display.text} - - ) : ( - display.text - )} - - + + {name} + +
) }) diff --git a/web/oss/src/state/newObservability/atoms/queries.ts b/web/oss/src/state/newObservability/atoms/queries.ts index a0ff0fefa6..26bf90a760 100644 --- a/web/oss/src/state/newObservability/atoms/queries.ts +++ b/web/oss/src/state/newObservability/atoms/queries.ts @@ -227,17 +227,6 @@ export const traceAnnotationInfoAtomFamily = atomFamily((key: string) => ) // Formatting helpers ---------------------------------------------------------- -export const nodeDisplayNameAtomFamily = atomFamily((name: string) => - atom(() => { - const truncated = name.length >= 15 - return { - text: truncated ? `${name.slice(0, 15)}...` : name, - full: name, - truncated, - } - }), -) - export const formattedTimestampAtomFamily = atomFamily((ts?: string) => atom(() => formatDay({date: ts, outputFormat: "HH:mm:ss DD MMM YYYY"})), ) diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx b/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx index d99d65945c..aaf13e663a 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx @@ -151,11 +151,23 @@ const InfiniteVirtualTableInnerBase = ({ ) const typeChipFeature = useTypeChipFeature(typeChips) - const finalColumns = useTypeChipColumns( + const typeChipColumns = useTypeChipColumns( resizableProcessedColumns, dataSource, typeChipFeature.typeChips, ) + + // Workaround for an AntD virtual-table layout quirk: after fast horizontal + // scrolling, header widths can drift away from body cell widths until + // *something* forces a column-level re-render. We bump `layoutNudge` after + // horizontal scroll settles to produce fresh column object references in + // `finalColumns`, which is enough to make AntD rebuild its layout state. + const [layoutNudge, setLayoutNudge] = useState(0) + + const finalColumns = useMemo( + () => (layoutNudge > 0 ? typeChipColumns.map((col) => ({...col})) : typeChipColumns), + [typeChipColumns, layoutNudge], + ) const columnDescendantMap = useMemo( () => buildColumnDescendantMap(resizableProcessedColumns), [resizableProcessedColumns], @@ -475,6 +487,31 @@ const InfiniteVirtualTableInnerBase = ({ visibilityRootRef.current = visibilityRoot ?? containerRef.current }, [visibilityRoot]) + // Bump layoutNudge after horizontal scroll settles. We only react to + // changes in scrollLeft so vertical scrolling (the common case) doesn't + // trigger an extra re-render on every pause. + useEffect(() => { + if (!scrollContainer) return + let timer: ReturnType | null = null + let lastScrollLeft = scrollContainer.scrollLeft + + const onScroll = () => { + if (scrollContainer.scrollLeft === lastScrollLeft) return + lastScrollLeft = scrollContainer.scrollLeft + if (timer) clearTimeout(timer) + timer = setTimeout(() => { + setLayoutNudge((prev) => prev + 1) + timer = null + }, 150) + } + + scrollContainer.addEventListener("scroll", onScroll, {passive: true}) + return () => { + scrollContainer.removeEventListener("scroll", onScroll) + if (timer) clearTimeout(timer) + } + }, [scrollContainer]) + const mergedComponents = useMemo(() => { if (!resizableHeaderComponents) { return resolvedTableProps.components diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/components/common/ResizableTitle.tsx b/web/packages/agenta-ui/src/InfiniteVirtualTable/components/common/ResizableTitle.tsx index db76ba5d69..0262347eb5 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/components/common/ResizableTitle.tsx +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/components/common/ResizableTitle.tsx @@ -1,4 +1,4 @@ -import {memo, useEffect, useMemo, useState} from "react" +import {memo, useMemo, useState} from "react" import type {ThHTMLAttributes} from "react" import {Skeleton} from "antd" @@ -22,20 +22,20 @@ export interface ResizableTitleProps extends Omit< export const ResizableTitle = memo((props: ResizableTitleProps) => { const {onResize, onResizeStart, onResizeStop, width, minWidth, ...restProps} = props - // Local live width to avoid forcing parent re-renders on every drag frame - const [liveWidth, setLiveWidth] = useState(width) + // liveWidth is set only during an active drag so the carries an inline + // width override for smooth visual feedback. When idle it's undefined and + // the cell is sized by AntD's , keeping header and body in sync. + const [liveWidth, setLiveWidth] = useState(undefined) + const isDragging = liveWidth !== undefined + const resolvedMinWidth = useMemo( () => (typeof minWidth === "number" ? minWidth : 48), [minWidth], ) - useEffect(() => { - setLiveWidth(width) - }, [width]) - // Only enable resizable behavior when a resize handler is provided. - // This ensures non-resizable columns (e.g., selection or fixed columns) - // are not wrapped in the Resizable component and keep their native layout. + // This ensures non-resizable columns (e.g., selection column) keep their + // native layout. if (!width || !onResize) { return } @@ -43,7 +43,10 @@ export const ResizableTitle = memo((props: ResizableTitleProps) => { onResizeStart?.(...args)} + onResizeStart={(e, data) => { + setLiveWidth(width ?? data.size.width) + onResizeStart?.(e, data) + }} handle={ { } onResize={(e: React.SyntheticEvent, data: ResizeCallbackData) => { setLiveWidth(data.size.width) - onResize && onResize(e, data) + onResize?.(e, data) + }} + onResizeStop={(e, data) => { + onResizeStop?.(e, data) + // Commit lives in the parent atom now — clear the drag override + // so subsequent renders source width from column.width via colgroup. + setLiveWidth(undefined) }} - onResizeStop={(...args) => onResizeStop?.(...args)} draggableOpts={{enableUserSelectHack: false}} > { ...restProps.style, paddingRight: 8, minWidth: resolvedMinWidth, - width: (liveWidth ?? width) || resolvedMinWidth || 160, + ...(isDragging ? {width: liveWidth} : null), }} className={cn([restProps.className, {"select-none": !!onResize}])} > diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts index ebf2a0b180..64734c2f93 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts @@ -81,6 +81,13 @@ export const useSmartResizableColumns = ({ const [userResizedWidths, setUserResizedWidths] = useAtom(widthsAtom) const [isResizing, setIsResizing] = useState(false) const columnMetaRef = useRef>({}) + // Snapshot of every child of a column group at drag start. The delta is + // then distributed proportionally across all children so the group expands + // uniformly instead of one column absorbing the entire change. + const groupResizeStartRef = useRef<{ + groupWidth: number + children: {key: string; width: number; minWidth: number}[] + } | null>(null) // Extract column metadata const analyzeColumns = useCallback( @@ -100,7 +107,13 @@ export const useSmartResizableColumns = ({ ? col.minWidth : DEFAULT_COLUMN_WIDTH - const resolvedMinWidth = typeof col.minWidth === "number" ? col.minWidth : minWidth + // For columns narrower than the default minWidth floor (e.g. a 61px + // actions column) honor the smaller width so the user can still drag + // them — otherwise the floor would exceed the column's intended size. + const resolvedMinWidth = + typeof col.minWidth === "number" + ? col.minWidth + : Math.min(minWidth, defaultWidth) const maxWidthValue = hasMaxWidth ? colWithMaxWidth.maxWidth : undefined @@ -128,18 +141,26 @@ export const useSmartResizableColumns = ({ const constrainedCols = columnsMeta.filter((c) => !c.isFixed && c.hasMaxWidth) const flexibleCols = columnsMeta.filter((c) => !c.isFixed && !c.hasMaxWidth) - // 2. Calculate fixed widths (these NEVER change) + // 2. Calculate widths reserved before flexible distribution let fixedWidth = selectionColumnWidth - // Fixed position columns use their ORIGINAL width (never user-resized) + // Fixed-position columns honor user-resized widths when present, + // otherwise fall back to their declared width. for (const col of fixedPositionCols) { - result[col.key] = col.width - fixedWidth += col.width + const userWidth = userResizedWidths[col.key] + const width = + userWidth !== undefined ? Math.max(userWidth, col.minWidth) : col.width + result[col.key] = width + fixedWidth += width } - // Constrained columns use their maxWidth + // maxWidth columns use their maxWidth as the default "reserved" size + // but a user drag overrides it (clamped only by minWidth — the user is + // explicitly opting out of the auto-layout cap). for (const col of constrainedCols) { - const width = col.maxWidth! + const userWidth = userResizedWidths[col.key] + const width = + userWidth !== undefined ? Math.max(userWidth, col.minWidth) : col.maxWidth! result[col.key] = width fixedWidth += width } @@ -219,11 +240,10 @@ export const useSmartResizableColumns = ({ const meta = columnMetaRef.current[colKey] if (!meta) return - const clamped = Math.max( - width, - meta.minWidth, - meta.maxWidth ? Math.min(width, meta.maxWidth) : width, - ) + // Only enforce the minWidth floor on user drags. maxWidth is a layout + // hint for the auto-distributor, not a hard ceiling — a deliberate + // resize overrides it. + const clamped = Math.max(width, meta.minWidth) setUserResizedWidths((prev) => { if (prev[colKey] === clamped) return prev @@ -237,11 +257,15 @@ export const useSmartResizableColumns = ({ ) const handleResize = useCallback( - (_colKey: string) => (_: unknown, _size: {size: {width: number}}) => { - // During drag, don't commit to state to avoid jank - // ResizableTitle handles visual feedback - }, - [], + (colKey: string) => + (_: unknown, {size}: {size: {width: number}}) => { + // Write width on every drag frame so AntD's updates + // and both header and body resize live together. The table uses + // `table-layout: fixed`, so inline width styles are ignored + // — the colgroup is the only path to a visible resize. + commitWidth(colKey, size.width) + }, + [commitWidth], ) const handleResizeStart = useCallback(() => { @@ -251,7 +275,6 @@ export const useSmartResizableColumns = ({ const handleResizeStop = useCallback( (colKey: string) => (_: unknown, {size}: {size: {width: number}}) => { - // Only commit width when drag ends for smooth performance commitWidth(colKey, size.width) setIsResizing(false) }, @@ -270,6 +293,58 @@ export const useSmartResizableColumns = ({ [handleResize, handleResizeStart, handleResizeStop], ) + // Column-group resize: dragging the parent header distributes the drag + // delta across every leaf child proportionally to their starting widths. + const applyGroupDelta = useCallback( + (newGroupWidth: number) => { + const start = groupResizeStartRef.current + if (!start || start.groupWidth <= 0) return + const delta = newGroupWidth - start.groupWidth + + setUserResizedWidths((prev) => { + const next = {...prev} + for (const child of start.children) { + const share = (child.width / start.groupWidth) * delta + next[child.key] = Math.max(child.width + share, child.minWidth) + } + return next + }) + }, + [setUserResizedWidths], + ) + + const buildGroupHeaderCellProps = useCallback( + ( + groupWidth: number, + children: {key: string; width: number; minWidth: number}[], + ): ResizableTitleProps => { + // Group floor is the sum of child minimums — the group can't shrink + // smaller than every child being at its own minimum simultaneously. + const minValue = + children.length > 0 + ? children.reduce((sum, c) => sum + c.minWidth, 0) + : DEFAULT_MIN_WIDTH + + return { + width: groupWidth, + minWidth: minValue, + onResizeStart: () => { + groupResizeStartRef.current = {groupWidth, children} + setIsResizing(true) + }, + onResize: (_: unknown, {size}: {size: {width: number}}) => { + applyGroupDelta(size.width) + }, + onResizeStop: (_: unknown, {size}: {size: {width: number}}) => { + applyGroupDelta(size.width) + groupResizeStartRef.current = null + setIsResizing(false) + }, + } + }, + [applyGroupDelta], + ) + const makeColumnsResizable = useCallback( ( cols: ColumnsType, @@ -288,17 +363,49 @@ export const useSmartResizableColumns = ({ : Math.random().toString(36))) as string const hasChildren = Boolean(column.children && column.children.length) - const isFixed = Boolean(column.fixed) if (hasChildren) { const nextChildren = makeColumnsResizable( column.children as ColumnsType, computedWidths, ) + + // Wire a resize handle on the group header. The drag delta + // is distributed proportionally across every leaf so the + // group expands uniformly. + const leafDescendants = collectLeafColumns( + nextChildren, + ) as ColumnType[] + const childSnapshots = leafDescendants + .map((leaf) => { + const leafKey = (leaf?.key ?? "") as string + const meta = columnMetaRef.current[leafKey] + if (!leafKey || !meta) return null + return { + key: leafKey, + width: computedWidths[leafKey] ?? meta.width, + minWidth: meta.minWidth, + } + }) + .filter((c): c is {key: string; width: number; minWidth: number} => + Boolean(c), + ) + + if (childSnapshots.length === 0) { + return { + ...column, + key: colKey, + children: nextChildren, + } as typeof colEntry + } + + const groupWidth = childSnapshots.reduce((sum, c) => sum + c.width, 0) + return { ...column, key: colKey, children: nextChildren, + onHeaderCell: () => buildGroupHeaderCellProps(groupWidth, childSnapshots), } as typeof colEntry } @@ -320,15 +427,6 @@ export const useSmartResizableColumns = ({ } as typeof colEntry } - if (isFixed) { - // Fixed position columns - keep their width but don't make resizable - return { - ...column, - key: colKey, - width, - } as typeof colEntry - } - return { ...column, key: colKey, @@ -337,7 +435,7 @@ export const useSmartResizableColumns = ({ onHeaderCell: () => buildHeaderCellProps(colKey, width, meta.minWidth), } as typeof colEntry }), - [buildHeaderCellProps], + [buildHeaderCellProps, buildGroupHeaderCellProps], ) const resizableColumns = useMemo(() => {