From d32fed28b558312d8dff311d884c931bc5842e05 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 25 May 2026 12:56:07 +0200 Subject: [PATCH 1/4] fix(frontend): make InfiniteVirtualTable columns properly resizable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sticky and maxWidth columns now honor user-resized widths and the resize handle is wired uniformly across fixed, constrained, and flexible columns. Column-group headers (e.g. Evaluators) become resizable; the drag delta distributes proportionally across leaf children so the group expands uniformly instead of one column absorbing the entire change. handleResize commits the new width on every drag frame so AntD's colgroup updates live — header and body resize together instead of snapping on release. ResizableTitle no longer carries an inline width when idle, so AntD's colgroup is the sole source of truth outside of an active drag. --- .../components/common/ResizableTitle.tsx | 34 ++-- .../hooks/useSmartResizableColumns.ts | 157 ++++++++++++++---- 2 files changed, 149 insertions(+), 42 deletions(-) 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..f819b90061 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,59 @@ 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 => { + // Use the smallest child minWidth as the floor for the parent th — + // any child can shrink to its own floor independently of the others. + const minValue = + children.reduce( + (acc, c) => (acc === null ? c.minWidth : Math.min(acc, c.minWidth)), + null as number | null, + ) ?? 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 +364,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 +428,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 +436,7 @@ export const useSmartResizableColumns = ({ onHeaderCell: () => buildHeaderCellProps(colKey, width, meta.minWidth), } as typeof colEntry }), - [buildHeaderCellProps], + [buildHeaderCellProps, buildGroupHeaderCellProps], ) const resizableColumns = useMemo(() => { From de7515737e6bbcff91492b57b19b73908188daec Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 25 May 2026 12:57:07 +0200 Subject: [PATCH 2/4] fix(frontend): re-sync InfiniteVirtualTable layout after fast scroll, anchor sticky-right resize handle inside cell Two table layout fixes: - After fast horizontal scrolling, AntD's body cell widths can drift away from the header colgroup until something forces a column-level re-render. A debounced scroll listener on .ant-table-body bumps a layoutNudge counter 150ms after horizontal scroll settles; the memoed finalColumns then produces fresh column object references and AntD rebuilds its layout state. The listener ignores vertical scroll so normal browsing has no extra re-render cost. - The resize handle on sticky-right columns (e.g. the observability Actions column) is anchored inside the cell (right: 0; width: 9px) instead of overhanging by 9px, removing the ~18px ghost slot that used to appear on the table's right edge. --- web/oss/src/assets/custom-resize-handle.css | 10 +++++ .../components/InfiniteVirtualTableInner.tsx | 40 ++++++++++++++++++- 2 files changed, 49 insertions(+), 1 deletion(-) 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/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx b/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx index aecf3e79da..6de1f9064d 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/components/InfiniteVirtualTableInner.tsx @@ -147,7 +147,20 @@ const InfiniteVirtualTableInnerBase = ({ [resizableProcessedColumns], ) - const finalColumns = resizableProcessedColumns + // 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 + ? resizableProcessedColumns.map((col) => ({...col})) + : resizableProcessedColumns, + [resizableProcessedColumns, layoutNudge], + ) const columnDescendantMap = useMemo( () => buildColumnDescendantMap(resizableProcessedColumns), [resizableProcessedColumns], @@ -467,6 +480,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 From fa356f604b8e0e5fc1b50cfc05be229a8b8d0ac6 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 25 May 2026 12:57:31 +0200 Subject: [PATCH 3/4] fix(frontend): use CSS ellipsis for observability Name cells NodeNameCell renders the full span name through Typography.Text with ellipsis={{tooltip: name}} so truncation tracks the rendered column width and the full name is reachable via tooltip. The nodeDisplayNameAtomFamily that hardcoded a 15-character JS slice is removed. --- .../observability/components/NodeNameCell.tsx | 23 ++++++------------- .../state/newObservability/atoms/queries.ts | 11 --------- 2 files changed, 7 insertions(+), 27 deletions(-) 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"})), ) From 5c65c938d15d073fd93853bedb8d8e0d6d00d6e5 Mon Sep 17 00:00:00 2001 From: Arda Erzin Date: Mon, 25 May 2026 17:37:56 +0200 Subject: [PATCH 4/4] fix(frontend): use sum of child mins as the group resize floor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A column group can't shrink smaller than every child being at its own minimum simultaneously, so the parent floor must be the sum of child minimums, not the smallest. No behavioural change today because table-layout: fixed ignores the parent th's minWidth — this is a hygiene fix that becomes load-bearing the moment we add minConstraints to the Resizable handle. --- .../hooks/useSmartResizableColumns.ts | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts index f819b90061..64734c2f93 100644 --- a/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts +++ b/web/packages/agenta-ui/src/InfiniteVirtualTable/hooks/useSmartResizableColumns.ts @@ -318,13 +318,12 @@ export const useSmartResizableColumns = ({ groupWidth: number, children: {key: string; width: number; minWidth: number}[], ): ResizableTitleProps => { - // Use the smallest child minWidth as the floor for the parent th — - // any child can shrink to its own floor independently of the others. + // 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.reduce( - (acc, c) => (acc === null ? c.minWidth : Math.min(acc, c.minWidth)), - null as number | null, - ) ?? DEFAULT_MIN_WIDTH + children.length > 0 + ? children.reduce((sum, c) => sum + c.minWidth, 0) + : DEFAULT_MIN_WIDTH return { width: groupWidth,