diff --git a/specs/datasets.md b/specs/datasets.md index e85ad49..028f426 100644 --- a/specs/datasets.md +++ b/specs/datasets.md @@ -1021,7 +1021,9 @@ that one `DatasetRowUpdate` can address: - **An instance's argument**, reached directly (`seededTask` › `title`) or through references (`toolsContext` › … › `rootTaskId` › `title`). The edit replaces that instance in the update's `resources` with the argument changed. Editing the focus task's title from the grid is the case - this exists for. + this exists for. Clearing one in place (Delete, or emptying the editor) does nothing: it would + delete the argument, and an argument that isn't there has nothing to type into. The details pane + removes an argument. Anything nested inside an argument's value object stays read-only for now. `resources` updates replace a whole instance, which is fine for a top-level argument but would mean rebuilding a nested @@ -1037,7 +1039,9 @@ value from a path. The details pane already edits those. `dataset-provider-contract.ts`. 3. `columns` and `rowLabel`: the migration, `setColumns`, the route, validation, and `deleteField` pruning its columns. The server checks that a path's root exists in the schema or - in some row, and that `keys` are non-empty strings. + in some row, and that `keys` are non-empty strings. An instance root the stored layout or row + label already has is accepted even when no row declares it any more, so a layout can always be + saved; it reads `"absent"` until a row declares the instance again. 4. `reconcileColumns`, as a pure function next to `parseTraceColumns`'s pattern. Unit tests cover a dropped field, a new field and a new key being appended, a duplicate path, and a stored list with no `columns`, which must produce today's layout. diff --git a/src/client/api.ts b/src/client/api.ts index 5299df2..6771694 100644 --- a/src/client/api.ts +++ b/src/client/api.ts @@ -14,6 +14,7 @@ import type { TraceCheckResult, } from "../eval/eval-types"; import type { AgentContext, AgentInfo } from "../shared/agent"; +import type { ColumnPath, DatasetColumnSpec } from "../shared/column-paths"; import type { AddDatasetFieldRequest } from "../shared/dataset-fields"; import type { SetupTask } from "../shared/setup-task"; import type { @@ -24,6 +25,7 @@ import type { ConflictChoices, Dataset, DatasetField, + DatasetPathChild, DatasetProviderInfo, DatasetRow, DatasetRowsOverview, @@ -778,6 +780,43 @@ export async function deleteDatasetField( await throwIfError(res); } +/** + * The keys one level inside `path` in a dataset's rows, each with a sample + * value — what the column picker's path tree expands a node into. + */ +export async function describeDatasetPath( + providerId: string, + datasetId: string, + path: ColumnPath, +): Promise { + const res = await fetch(datasetUrl(providerId, datasetId, "/paths"), { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ path }), + }); + await throwIfError(res); + return res.json(); +} + +/** + * Replaces a dataset's shared column layout and row label (`null` clears + * it). Resolves to the updated dataset. + */ +export async function setDatasetColumns( + providerId: string, + datasetId: string, + columns: DatasetColumnSpec[], + rowLabel: ColumnPath | null, +): Promise { + const res = await fetch(datasetUrl(providerId, datasetId, "/columns"), { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ columns, rowLabel }), + }); + await throwIfError(res); + return res.json(); +} + /** Deletes one row of a dataset. */ export async function deleteDatasetRow( providerId: string, diff --git a/src/client/components/AskAgentButton.tsx b/src/client/components/AskAgentButton.tsx index ab6c782..7396796 100644 --- a/src/client/components/AskAgentButton.tsx +++ b/src/client/components/AskAgentButton.tsx @@ -5,6 +5,7 @@ import { useState } from "react"; import { createPortal } from "react-dom"; import { type AgentInfo, OTHER_AGENT_URL } from "../../shared/agent"; import ProviderIcon from "./ProviderIcon"; +import { ChevronDownIcon } from "./trace/icons"; import { useAnchoredPopover } from "./use-anchored-popover"; interface Props { @@ -148,21 +149,3 @@ function AgentIcon() { ); } - -function ChevronDownIcon() { - return ( - - ); -} diff --git a/src/client/components/DatasetColumnMenu.tsx b/src/client/components/DatasetColumnMenu.tsx index 95c5ea2..2adbddd 100644 --- a/src/client/components/DatasetColumnMenu.tsx +++ b/src/client/components/DatasetColumnMenu.tsx @@ -8,8 +8,11 @@ import type { DatasetField } from "../../shared/types"; import { useAnchoredPopover } from "./use-anchored-popover"; interface Props { - /** The field the menu is for. */ - field: DatasetField; + /** + * The field the column is, when it's a field's whole column — which is + * what offers renaming and deleting. + */ + field?: DatasetField; /** The column header's place on screen, which the menu opens below. */ anchor: Rectangle; /** @@ -19,12 +22,20 @@ interface Props { onRename: (name: string) => Promise; /** The field is to be deleted; the caller confirms, then closes the menu. */ onDelete: () => void; + /** Hides the column. */ + onHide: () => void; + /** + * Whether the column is the row label, and makes it so or stops it being + * so — absent for a column that can't name a row (the source, say). + */ + rowLabel?: { isLabel: boolean; toggle: () => void }; /** Called when the menu is dismissed or has done its job. */ onClose: () => void; } /** - * The menu a dataset grid's column header opens: rename the field, or delete + * The menu a dataset grid's column header opens: hide the column, make it + * the row label, and — for a field's own column — rename the field or delete * it. Renaming swaps the menu for a name box in place. Placed against `anchor` * — the header cell's rectangle, which Glide reports but draws on a canvas — * through an invisible fixed element standing in for a trigger. @@ -34,10 +45,12 @@ export function DatasetColumnMenu({ anchor, onRename, onDelete, + onHide, + rowLabel, onClose, }: Props) { const [renaming, setRenaming] = useState(false); - const [name, setName] = useState(field.def.name); + const [name, setName] = useState(field?.def.name ?? ""); const [error, setError] = useState(null); const [busy, setBusy] = useState(false); const { triggerRef, popoverRef, style } = useAnchoredPopover( @@ -47,7 +60,7 @@ export function DatasetColumnMenu({ const submit = async () => { const trimmed = name.trim(); if (busy) return; - if (!trimmed || trimmed === field.def.name) return onClose(); + if (!trimmed || trimmed === field?.def.name) return onClose(); setBusy(true); try { await onRename(trimmed); @@ -125,18 +138,54 @@ export function DatasetColumnMenu({ type="button" role="menuitem" className="trace-header-menu-item" - onClick={() => setRenaming(true)} - > - Rename field… - - + {rowLabel && ( + + )} + {field && ( + <> + + + + )} )} , diff --git a/src/client/components/DatasetColumnPicker.tsx b/src/client/components/DatasetColumnPicker.tsx new file mode 100644 index 0000000..b58f743 --- /dev/null +++ b/src/client/components/DatasetColumnPicker.tsx @@ -0,0 +1,515 @@ +// SPDX-License-Identifier: AGPL-3.0-only +// Copyright (c) 2026 Alexander Corrado + +import { useState } from "react"; +import { createPortal } from "react-dom"; +import { + type ColumnPath, + columnPathId, + fieldPath, + instancePath, +} from "../../shared/column-paths"; +import type { + DatasetField, + DatasetPathChild, + DatasetRowsOverview, +} from "../../shared/types"; +import { + type ColumnLayout, + type LayoutColumn, + type LayoutSlot, + moveColumn, + moveKey, + setHidden, + slotPath, +} from "./dataset-columns"; +import { addedTitle, pathLabel } from "./dataset-grid"; +import { previewCell } from "./dataset-preview"; +import { + ChevronDownIcon, + ColumnsIcon, + GrabHandleIcon, +} from "./trace/icons.tsx"; +import { useAnchoredPopover } from "./use-anchored-popover"; + +interface Props { + /** The reconciled layout ({@link reconcileColumns}). */ + layout: ColumnLayout; + fields: readonly DatasetField[]; + /** The rows' resource instances, offered as roots by "Add column…". */ + instances: DatasetRowsOverview["instances"]; + /** The path that names a row, if any. */ + rowLabel: ColumnPath | undefined; + /** Writes a changed layout. A drag writes once, on drop. */ + onChange: (layout: ColumnLayout) => void; + /** Sets the row label, or clears it with `undefined`. */ + onRowLabel: (path: ColumnPath | undefined) => void; + /** Adds a column at `path`. */ + onAdd: (path: ColumnPath) => void; + /** + * Removes the added column at `index` — and the row label with it, if + * that column was the label, since no menu would be left to clear it. + */ + onRemove: (index: number) => void; + /** The keys one level inside a path, for "Add column…"'s tree. */ + describePath: (path: ColumnPath) => Promise; +} + +/** A slot as one string, to compare and key rows by. */ +const slotKey = (slot: LayoutSlot) => + slot.key === undefined ? `${slot.index}` : `${slot.index}/${slot.key}`; + +/** + * The dataset view's "Columns" button and its popover: one row per column, + * in the layout's order, each with a grab handle, a checkbox that hides or + * shows it, and its title. A field with keys discloses them as indented + * rows, which reorder only within that field; an added column's row has a + * ✕ that removes it; and each path's overflow menu sets the row label. + * "Add column…" at the bottom browses the rows' paths as a tree. Reordering + * is a live swap off the grab handle, as the trace list's picker does, but + * written once, when the drag ends. See `specs/datasets.md` §Q.3. + */ +export function DatasetColumnPicker({ + layout, + fields, + instances, + rowLabel, + onChange, + onRowLabel, + onAdd, + onRemove, + describePath, +}: Props) { + const [open, setOpen] = useState(false); + const [adding, setAdding] = useState(false); + /** The layout as a drag in progress has it; written when the drag ends. */ + const [draft, setDraft] = useState(null); + const [drag, setDrag] = useState(null); + /** Fields whose keys are disclosed, by id. */ + const [disclosed, setDisclosed] = useState>(new Set()); + /** The row whose overflow menu is open. */ + const [menuFor, setMenuFor] = useState(null); + const close = () => { + setOpen(false); + setAdding(false); + setMenuFor(null); + }; + const { triggerRef, popoverRef, style } = + useAnchoredPopover({ + open, + onClose: close, + matchTriggerWidth: false, + }); + + const shown = draft ?? layout; + const labelId = rowLabel && columnPathId(rowLabel); + const fieldName = (id: string) => + fields.find(f => f.id === id)?.def.name ?? id; + + const dragOver = (target: LayoutSlot) => { + if (!drag || !draft) return false; + if (drag.key === undefined && target.key === undefined) { + if (target.index !== drag.index) { + setDraft(moveColumn(draft, drag.index, target.index)); + setDrag({ index: target.index }); + } + return true; + } + // A key moves only within its own field. + const entry = draft[drag.index]; + if ( + drag.key === undefined || + target.key === undefined || + target.index !== drag.index || + entry?.kind !== "field" + ) { + return false; + } + if (target.key !== drag.key) { + const from = entry.keys.findIndex(k => k.key === drag.key); + const to = entry.keys.findIndex(k => k.key === target.key); + setDraft(moveKey(draft, drag.index, from, to)); + } + return true; + }; + + const endDrag = () => { + if (draft && draft !== layout) onChange(draft); + setDraft(null); + setDrag(null); + }; + + const pickerRow = ({ + slot, + title, + detail, + hidden, + removable, + disclosure, + dim, + }: { + slot: LayoutSlot; + title: string; + /** Dimmed beside the title. */ + detail?: string; + hidden: boolean; + removable?: boolean; + disclosure?: { open: boolean; toggle: () => void }; + dim?: boolean; + }) => { + const key = slotKey(slot); + const path = slotPath(shown, slot); + const isLabel = !!path && columnPathId(path) === labelId; + return ( +
{ + if (dragOver(slot)) e.preventDefault(); + }} + onDrop={e => e.preventDefault()} + > + { + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", key); + setDraft(layout); + setDrag(slot); + setMenuFor(null); + }} + onDragEnd={endDrag} + title="Drag to reorder" + > + + + {disclosure ? ( + + ) : ( + slot.key === undefined && ( + + ) + )} + + {path && ( + + + {menuFor === key && ( +
+ +
+ )} +
+ )} + {removable && ( + + )} +
+ ); + }; + + const topRow = (column: LayoutColumn, index: number) => { + const slot = { index }; + switch (column.kind) { + case "field": { + const isOpen = disclosed.has(column.fieldId); + const row = pickerRow({ + slot, + title: fieldName(column.fieldId), + hidden: column.hidden, + ...(column.keys.length > 0 && { + disclosure: { + open: isOpen, + toggle: () => { + const next = new Set(disclosed); + if (!next.delete(column.fieldId)) next.add(column.fieldId); + setDisclosed(next); + }, + }, + }), + }); + if (!isOpen) return row; + return [ + row, + ...column.keys.map(k => + pickerRow({ + slot: { index, key: k.key }, + title: k.key, + hidden: k.hidden, + }), + ), + ]; + } + case "added": + return pickerRow({ + slot, + title: column.title ?? addedTitle(column.path, fields), + detail: pathLabel(column.path, fields), + hidden: column.hidden, + removable: true, + }); + case "source": + return pickerRow({ slot, title: "source", hidden: column.hidden }); + case "unknown": + return pickerRow({ + slot, + title: "Unknown column", + detail: column.spec.kind, + hidden: column.hidden, + dim: true, + }); + } + }; + + return ( + <> + + {open && + createPortal( +
+ {adding ? ( + { + onAdd(path); + setAdding(false); + }} + onBack={() => setAdding(false)} + /> + ) : ( + <> + {shown.flatMap((column, i) => topRow(column, i))} + + + )} +
, + document.body, + )} + + ); +} + +/** + * "Add column…": the rows' paths as a tree — the fields, then the resource + * instances — each node expanding a level at a time, with a sample of what + * the first row holds there dimmed beside it. Picking a node adds a column + * at its path. See `specs/datasets.md` §Q.2. + */ +function PathTree({ + fields, + instances, + describePath, + onPick, + onBack, +}: { + fields: readonly DatasetField[]; + instances: DatasetRowsOverview["instances"]; + describePath: (path: ColumnPath) => Promise; + onPick: (path: ColumnPath) => void; + onBack: () => void; +}) { + return ( +
+ + {fields.map(f => ( + + ))} + {Object.entries(instances).map(([name, shape]) => ( + + ))} +
+ ); +} + +/** One node of {@link PathTree}, fetching its children when first expanded. */ +function PathNode({ + path, + label, + detail, + depth, + describePath, + onPick, +}: { + path: ColumnPath; + label: string; + detail?: string; + depth: number; + describePath: (path: ColumnPath) => Promise; + onPick: (path: ColumnPath) => void; +}) { + const [expanded, setExpanded] = useState(false); + const [children, setChildren] = useState(null); + const [error, setError] = useState(null); + + const toggle = () => { + const next = !expanded; + setExpanded(next); + if (next && children === null) { + describePath(path).then(setChildren, (err: Error) => + setError(err.message), + ); + } + }; + + return ( +
+
+ + +
+ {expanded && ( +
+ {error &&
{error}
} + {children === null && !error && ( +
+ Loading… +
+ )} + {children?.map(child => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/client/components/DatasetView.tsx b/src/client/components/DatasetView.tsx index c5a2b70..95cc6cc 100644 --- a/src/client/components/DatasetView.tsx +++ b/src/client/components/DatasetView.tsx @@ -38,6 +38,11 @@ import { } from "react"; import { createPortal } from "react-dom"; import { shortSyntax } from "ts-proppy/react"; +import { + type ColumnPath, + columnPathId, + pathField, +} from "../../shared/column-paths"; import type { Dataset, DatasetField, @@ -52,24 +57,38 @@ import { deleteDataset, deleteDatasetField, deleteDatasetRow, + describeDatasetPath, getDataset, getDatasetRows, renameDataset, renameDatasetField, + setDatasetColumns, updateDatasetRows, } from "../api"; import { useStructurallyStable } from "../hooks/useStructurallyStable"; import { DatasetAddField } from "./DatasetAddField"; import { DatasetColumnMenu } from "./DatasetColumnMenu"; +import { DatasetColumnPicker } from "./DatasetColumnPicker"; import { DatasetRowDetails } from "./DatasetRowDetails"; import { DetailsPane, DetailsPaneHeader, useIsWide } from "./DetailsPane"; +import { + addColumn, + type ColumnLayout, + reconcileColumns, + removeColumn, + serializeColumns, + setHidden, + slotPath, +} from "./dataset-columns"; import { buildColumns, type CellEdit, type CellView, + canMoveColumn, cellEdits, cellView, clearEdits, + columnPath, type DatasetColumn, type DatasetLayout, DEFAULT_LAYOUT, @@ -83,8 +102,10 @@ import { layoutStorageKey, layoutWithoutField, menuFieldId, + movedLayout, parseDatasetLayout, RowPager, + rowLabelText, serializeDatasetLayout, } from "./dataset-grid"; import { datasetInputSources } from "./dataset-row-editing"; @@ -307,6 +328,13 @@ function toGridCell(view: CellView, theme: Partial): GridCell { bgCell: theme.bgCellMedium, }, }); + case "unknown": + return text("", { + themeOverride: { + textDark: theme.textLight, + bgCell: theme.bgCellMedium, + }, + }); case "chip": return { kind: GridCellKind.Bubble, @@ -357,6 +385,7 @@ function DatasetView({ const [overview, setOverview] = useState({ rowCount: 0, fields: {}, + instances: {}, }); const [error, setError] = useState(null); /** Bumped after a local change, to refetch as a `version` bump would. */ @@ -410,9 +439,9 @@ function DatasetView({ onClose: closeMenu, matchTriggerWidth: false, }); - /** The column header menu that's open: its field, and where it opened from. */ + /** The column header menu that's open: its column, and where it opened from. */ const [columnMenu, setColumnMenu] = useState<{ - fieldId: string; + columnId: string; anchor: Rectangle; } | null>(null); const closeColumnMenu = useCallback(() => setColumnMenu(null), []); @@ -443,12 +472,13 @@ function DatasetView({ useEffect(() => { let cancelled = false; getDataset(providerId, datasetId) - .then(({ dataset, rowCount, fields }) => { + .then(({ dataset, rowCount, fields, instances }) => { if (cancelled) return; setDataset(dataset); setOverview({ rowCount: rowCount + pendingAppends.current.count, fields, + instances: instances ?? {}, }); setError(appendError.current); appendError.current = null; @@ -470,17 +500,54 @@ function DatasetView({ dataset ? datasetInputSources(dataset.fields, linkedPrompt) : undefined, ); + /** The dataset's shared column layout, fitted to its fields and rows. */ + const columnLayout = useMemo( + () => + dataset + ? reconcileColumns(dataset.columns, dataset.fields, overview.fields) + : [], + [dataset, overview.fields], + ); const columns = useMemo( () => dataset ? buildColumns({ fields: dataset.fields, - shape: overview.fields, + layout: columnLayout, expanded, shortType: syntax => shortSyntax(syntax, 24), }) : [], - [dataset, overview.fields, expanded], + [dataset, columnLayout, expanded], + ); + const rowLabel = dataset?.rowLabel; + + /** + * Writes the shared layout and row label (`null` clears the label; left + * out, it stays as it is). Optimistic, as a cell edit is: + * the grid redraws at once, and the `dataset-changed` refetch confirms. A + * failure says why and reloads the dataset as the server has it. + */ + const saveColumns = useCallback( + (next: ColumnLayout, label: ColumnPath | null = rowLabel ?? null) => { + const serialized = serializeColumns(next); + setDataset(d => { + if (!d) return d; + const { rowLabel: _, ...rest } = d; + return { + ...rest, + columns: serialized, + ...(label && { rowLabel: label }), + }; + }); + setDatasetColumns(providerId, datasetId, serialized, label).catch( + (err: Error) => { + setError(`Couldn't save the columns: ${err.message}`); + setReload(n => n + 1); + }, + ); + }, + [providerId, datasetId, rowLabel], ); const fieldIdByGroup = useMemo(() => fieldIdsByGroup(columns), [columns]); const getGroupDetails = useCallback( @@ -498,10 +565,13 @@ function DatasetView({ id: c.id, title: c.title, width: widths[c.id] ?? c.width, - hasMenu: menuFieldId(c) !== undefined, + hasMenu: true, ...(c.group !== undefined && { group: c.group }), + ...(c.role === "unknown" && { + themeOverride: { textHeader: theme.textLight }, + }), })), - [columns, widths], + [columns, widths, theme], ); // biome-ignore lint/correctness/useExhaustiveDependencies: `loaded` redraws cells as their pages land. @@ -770,9 +840,10 @@ function DatasetView({ const onGroupHeaderClicked = useCallback( (col: number, event: { preventDefault: () => void }) => { const column = columns[col]; - if (!column || !("path" in column)) return; + const fieldId = + column && "path" in column ? pathField(column.path) : undefined; + if (fieldId === undefined) return; event.preventDefault(); - const { fieldId } = column.path; const next = new Set(expanded); if (!next.delete(fieldId)) next.add(fieldId); updateLayout({ ...layout, expanded: next }); @@ -782,12 +853,27 @@ function DatasetView({ const onHeaderMenuClick = useCallback( (col: number, anchor: Rectangle) => { - const fieldId = menuFieldId(columns[col]); - if (fieldId !== undefined) setColumnMenu({ fieldId, anchor }); + const column = columns[col]; + if (column) setColumnMenu({ columnId: column.id, anchor }); }, [columns], ); + /** Refuses a header drag that would split a field's keys up (§Q.3). */ + const onColumnProposeMove = useCallback( + (from: number, to: number) => canMoveColumn(columns, from, to), + [columns], + ); + + /** A header was dragged to a new place: one write, on drop. */ + const onColumnMoved = useCallback( + (from: number, to: number) => { + const next = movedLayout(columnLayout, columns, from, to); + if (next !== columnLayout) saveColumns(next); + }, + [columnLayout, columns, saveColumns], + ); + const act = async (action: () => Promise) => { try { await action(); @@ -825,9 +911,16 @@ function DatasetView({ setRenaming(true); }; - const menuField = columnMenu - ? dataset.fields.find(f => f.id === columnMenu.fieldId) + const menuColumn = columnMenu + ? columns.find(c => c.id === columnMenu.columnId) : undefined; + const menuFieldIdValue = menuFieldId(menuColumn); + const menuField = + menuFieldIdValue !== undefined + ? dataset.fields.find(f => f.id === menuFieldIdValue) + : undefined; + const menuPath = columnPath(menuColumn); + const labelId = rowLabel && columnPathId(rowLabel); const renameField = async (field: DatasetField, name: string) => { const renamed = await renameDatasetField( @@ -877,17 +970,6 @@ function DatasetView({ menuOpen && createPortal(
- {onAskAgent && ( - !agent.disabledReason)} - onAsk={agent => { - setMenuOpen(false); - onAskAgent(agent); - }} - /> - )} {onNewEval && ( )} + {onAskAgent && ( + !agent.disabledReason)} + onAsk={agent => { + setMenuOpen(false); + onAskAgent(agent); + }} + /> + )} + {(onNewEval || onAskAgent) &&
}
, @@ -922,10 +1022,20 @@ function DatasetView({ }), }); + const selectedLabel = + selectedRow && rowLabel ? rowLabelText(selectedRow, rowLabel) : undefined; + const rowDetails = selectedRow && selectedIndex !== null && ( <> Row {selectedIndex + 1}} + title={ + + Row {selectedIndex + 1} + {selectedLabel !== undefined && ( + · {selectedLabel} + )} + + } actions={ <> {linkedPrompt && ( @@ -1005,10 +1115,28 @@ function DatasetView({ )}
+ saveColumns(next)} + onRowLabel={path => saveColumns(columnLayout, path ?? null)} + onAdd={path => saveColumns(addColumn(columnLayout, path))} + onRemove={index => { + const path = slotPath(columnLayout, { index }); + saveColumns( + removeColumn(columnLayout, index), + path && columnPathId(path) === labelId + ? null + : (rowLabel ?? null), + ); + }} + describePath={path => + describeDatasetPath(providerId, datasetId, path) + } + />
- {onAskAgent && ( - - )} {onNewEval && (
{menu} - {columnMenu && menuField && ( + {columnMenu && menuColumn && ( renameField(menuField, name)} - onDelete={() => handleDeleteField(menuField)} + onRename={async name => { + if (menuField) await renameField(menuField, name); + }} + onDelete={() => menuField && handleDeleteField(menuField)} + onHide={() => + saveColumns(setHidden(columnLayout, menuColumn.slot, true)) + } + {...(menuPath && { + rowLabel: { + isLabel: columnPathId(menuPath) === labelId, + toggle: () => + saveColumns( + columnLayout, + columnPathId(menuPath) === labelId ? null : menuPath, + ), + }, + })} onClose={closeColumnMenu} /> )} @@ -1146,6 +1292,8 @@ function DatasetView({ onCellActivated={onCellActivated} onGroupHeaderClicked={onGroupHeaderClicked} onHeaderMenuClick={onHeaderMenuClick} + onColumnProposeMove={onColumnProposeMove} + onColumnMoved={onColumnMoved} onCellsEdited={onCellsEdited} validateCell={validateCell} onDelete={onDelete} diff --git a/src/client/components/NewEvalDialog.tsx b/src/client/components/NewEvalDialog.tsx index 565901e..f815f4d 100644 --- a/src/client/components/NewEvalDialog.tsx +++ b/src/client/components/NewEvalDialog.tsx @@ -139,6 +139,7 @@ function NewEvalDialog({ setName(e.target.value)} diff --git a/src/client/components/TraceList.tsx b/src/client/components/TraceList.tsx index ee1f7a8..24a1c54 100644 --- a/src/client/components/TraceList.tsx +++ b/src/client/components/TraceList.tsx @@ -24,7 +24,9 @@ import { import { AnnotationsIcon, CalendarIcon, + ColumnsIcon, CostIcon, + GrabHandleIcon, ModelIcon, SpansIcon, StopwatchIcon, @@ -118,46 +120,6 @@ function AnnotationCountBadges({ ); } -function ColumnsIcon() { - return ( - - - - - - ); -} - -/** A 2×3 grid of dots — the column picker's drag-to-reorder grab handle. */ -function GrabHandleIcon() { - return ( - - - - - - - - - ); -} - /** * Trigger + popover for showing/hiding and reordering {@link TraceColumnState}s. * Reordering is native HTML5 drag-and-drop off each row's grab handle: the diff --git a/src/client/components/TraceView.tsx b/src/client/components/TraceView.tsx index e592d2a..398d9c5 100644 --- a/src/client/components/TraceView.tsx +++ b/src/client/components/TraceView.tsx @@ -391,15 +391,6 @@ function TraceView({
- {onAskAgent && ( - - )} - {addToDatasetProps && ( - - )} + {addToDatasetProps && ( + + )} + {onAskAgent && ( + + )} {onDeleted && (