From 5d07a8b4b2f25b463d01c283a84e26cf341f0c32 Mon Sep 17 00:00:00 2001 From: MMeteorL Date: Mon, 1 Jun 2026 18:51:29 -0700 Subject: [PATCH 1/4] Add cell expand side sheet with sources Hovering any table cell reveals a Maximize2 icon. Clicking it opens a slide-in panel showing the column name, type, description, full cell value with a copy button, and (when available) the row-level sources the populate agent saved alongside the data. Changes: - SideSheet.tsx: new component with backdrop, Escape/Tab trap, scroll lock, and slide-in animation. CellDetail sub-component shows column metadata, value (with inline "Copied" feedback), and sources as clickable external links. - types.ts: add optional `sources?: string[]` to DatasetRow - DataRow.tsx: `group` + Maximize2 button on hover per cell; `onCellExpand` callback added to DataRowData interface. - DatasetTable.tsx: accept and forward `onCellExpand` prop. - page.tsx: cellDetail state, row sources lookup, SideSheet render. - globals.css: `slide-in` keyframe + `.animate-slide-in` utility. Co-Authored-By: Claude Sonnet 4.6 --- frontend/app/dataset/[id]/page.tsx | 24 +++ frontend/app/globals.css | 9 + frontend/components/SideSheet.tsx | 181 +++++++++++++++++++++ frontend/components/table/DataRow.tsx | 17 +- frontend/components/table/DatasetTable.tsx | 5 +- frontend/components/table/types.ts | 1 + 6 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 frontend/components/SideSheet.tsx diff --git a/frontend/app/dataset/[id]/page.tsx b/frontend/app/dataset/[id]/page.tsx index adba9a0..69be02c 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -9,6 +9,7 @@ import { api } from "@/convex/_generated/api"; import type { Id } from "@/convex/_generated/dataModel"; import { DatasetTable } from "@/components/table"; import { useSelection } from "@/components/table/use-selection"; +import { SideSheet, CellDetail } from "@/components/SideSheet"; import { useTheme } from "@/components/ThemeToggle"; import { StatusBadge } from "@/components/dataset/StatusBadge"; import { downloadCSV, downloadXLSX } from "@/lib/export"; @@ -34,6 +35,11 @@ export default function DatasetPage() { const [settingsOpen, setSettingsOpen] = useState(false); const [confirmPopulate, setConfirmPopulate] = useState(false); const [savingRefreshCadence, setSavingRefreshCadence] = useState(false); + const [cellDetail, setCellDetail] = useState<{ + columnName: string; + value: unknown; + sources?: string[]; + } | null>(null); const datasetId = params.id as Id<"datasets">; const dataset = useQuery( @@ -326,8 +332,26 @@ export default function DatasetPage() { rows={rows} datasetId={datasetId} selection={selection} + onCellExpand={(columnName, value, rowId) => { + const row = rows.find((r) => r._id === rowId); + setCellDetail({ columnName, value, sources: row?.sources }); + }} /> + setCellDetail(null)}> + {cellDetail && (() => { + const col = dataset.columns.find((c) => c.name === cellDetail.columnName); + if (!col) return null; + return ( + + ); + })()} + + {confirmPopulate && ( void; + children: React.ReactNode; +} + +export function SideSheet({ open, onClose, children }: SideSheetProps) { + const panelRef = useRef(null); + + // Lock body scroll while open. + useEffect(() => { + if (!open) return; + const prev = document.body.style.overflow; + document.body.style.overflow = "hidden"; + return () => { document.body.style.overflow = prev; }; + }, [open]); + + // Keyboard: Escape closes, Tab stays trapped inside. + useEffect(() => { + if (!open || !panelRef.current) return; + const panel = panelRef.current; + + const focusable = panel.querySelectorAll( + 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])', + ); + focusable[0]?.focus(); + + function onKey(e: KeyboardEvent) { + if (e.key === "Escape") { onClose(); return; } + if (e.key !== "Tab" || focusable.length === 0) return; + const first = focusable[0]; + const last = focusable[focusable.length - 1]; + if (e.shiftKey && document.activeElement === first) { + e.preventDefault(); last.focus(); + } else if (!e.shiftKey && document.activeElement === last) { + e.preventDefault(); first.focus(); + } + } + + panel.addEventListener("keydown", onKey); + return () => panel.removeEventListener("keydown", onKey); + }, [open, onClose]); + + if (!open) return null; + + return ( +
+ {/* Backdrop */} +
+ {/* Panel */} +
+
+

Cell Detail

+ +
+
{children}
+
+
+ ); +} + +/* ------------------------------------------------------------------ */ +/* Content */ +/* ------------------------------------------------------------------ */ + +interface CellDetailProps { + column: DatasetColumn; + value: unknown; + /** Row-level sources stored by the populate agent. */ + sources?: string[]; +} + +export function CellDetail({ column, value, sources }: CellDetailProps) { + const [copied, setCopied] = useState(false); + const displayValue = value == null || value === "" ? "—" : String(value); + + async function handleCopy() { + try { + await navigator.clipboard.writeText(value == null ? "" : String(value)); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } catch { + // Clipboard API unavailable (e.g. non-HTTPS dev); silently ignore. + } + } + + return ( +
+ {/* Column name + description */} +
+

{column.name}

+ {column.description && ( +

{column.description}

+ )} +
+ + {/* Type */} +
+

+ Type +

+

{column.type}

+
+ + {/* Value */} +
+
+

+ Value +

+ +
+
+

+ {displayValue} +

+
+
+ + {/* Sources */} + {sources && sources.length > 0 && ( +
+

+ Sources +

+ +
+ )} +
+ ); +} diff --git a/frontend/components/table/DataRow.tsx b/frontend/components/table/DataRow.tsx index 1fe0974..0927963 100644 --- a/frontend/components/table/DataRow.tsx +++ b/frontend/components/table/DataRow.tsx @@ -4,6 +4,7 @@ import { memo, type CSSProperties } from "react"; import { areEqual } from "react-window"; import type { Row } from "@tanstack/react-table"; import type { DatasetRow, DatasetColumn } from "./types"; +import { Maximize2 } from "lucide-react"; import { CellValue } from "./CellValue"; import { floorWidth } from "./utils"; @@ -13,6 +14,7 @@ export interface DataRowData { columnWidths: number[]; isSelected: (id: string) => boolean; toggleRow: (id: string, shiftKey: boolean) => void; + onCellExpand: (columnName: string, value: unknown, rowId: string) => void; isBuilding: boolean; pendingRowIds: Set; flashingCells: Set; @@ -27,7 +29,7 @@ function DataRowImpl({ index: number; style: CSSProperties; }) { - const { rows, columns, columnWidths, isSelected, toggleRow, isBuilding, pendingRowIds, flashingCells } = data; + const { rows, columns, columnWidths, isSelected, toggleRow, onCellExpand, isBuilding, pendingRowIds, flashingCells } = data; const row = rows[index]; if (!row) { @@ -112,7 +114,7 @@ function DataRowImpl({
+ {isPending &&
}
); diff --git a/frontend/components/table/DatasetTable.tsx b/frontend/components/table/DatasetTable.tsx index f921875..e74c2bb 100644 --- a/frontend/components/table/DatasetTable.tsx +++ b/frontend/components/table/DatasetTable.tsx @@ -85,11 +85,13 @@ export function DatasetTable({ rows, datasetId, selection, + onCellExpand, }: { dataset: DatasetMeta; rows: DatasetRow[]; datasetId: string; selection: Selection; + onCellExpand: (columnName: string, value: unknown, rowId: string) => void; }) { const tableContainerRef = useRef(null); const previousResizingColumnIdRef = useRef(false); @@ -168,11 +170,12 @@ export function DatasetTable({ columnWidths, isSelected: selection.has, toggleRow, + onCellExpand, isBuilding, pendingRowIds, flashingCells, }), - [tableRows, dataset.columns, columnWidths, selection.has, toggleRow, isBuilding, pendingRowIds, flashingCells], + [tableRows, dataset.columns, columnWidths, selection.has, toggleRow, onCellExpand, isBuilding, pendingRowIds, flashingCells], ); return ( diff --git a/frontend/components/table/types.ts b/frontend/components/table/types.ts index 5fc380c..6eff009 100644 --- a/frontend/components/table/types.ts +++ b/frontend/components/table/types.ts @@ -28,5 +28,6 @@ export interface DatasetRow { _id: string; _creationTime: number; data: Record; + sources?: string[]; updateStatus?: "pending"; } From 702f32d03a5531941537626510f3e45d57a8430d Mon Sep 17 00:00:00 2001 From: MMeteorL Date: Mon, 1 Jun 2026 19:00:32 -0700 Subject: [PATCH 2/4] Fix lucide-react module-not-found by using inline SVGs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lucide-react is in bun.lock but not installed in the running Docker container's node_modules. Existing components (ColumnHeader, ColumnIcon, ThemeToggle) all use inline SVGs — follow the same pattern instead of adding a runtime dependency. Replaced: Copy, Check, ExternalLink, X in SideSheet.tsx and Maximize2 in DataRow.tsx with self-contained SVG components. Co-Authored-By: Claude Sonnet 4.6 --- frontend/components/SideSheet.tsx | 38 +++++++++++++++++++++++---- frontend/components/table/DataRow.tsx | 11 ++++++-- 2 files changed, 42 insertions(+), 7 deletions(-) diff --git a/frontend/components/SideSheet.tsx b/frontend/components/SideSheet.tsx index fee57cd..07b0329 100644 --- a/frontend/components/SideSheet.tsx +++ b/frontend/components/SideSheet.tsx @@ -1,9 +1,37 @@ "use client"; import { useEffect, useRef, useState } from "react"; -import { Copy, Check, ExternalLink, X } from "lucide-react"; import type { DatasetColumn } from "@/components/table/types"; +function IconX() { + return ( + + ); +} +function IconCopy() { + return ( + + ); +} +function IconCheck() { + return ( + + ); +} +function IconExternalLink() { + return ( + + ); +} + /* ------------------------------------------------------------------ */ /* Shell */ /* ------------------------------------------------------------------ */ @@ -72,7 +100,7 @@ export function SideSheet({ open, onClose, children }: SideSheetProps) { className="inline-flex items-center justify-center h-7 w-7 text-muted hover:text-foreground transition-colors rounded" aria-label="Close" > - +
{children}
@@ -137,8 +165,8 @@ export function CellDetail({ column, value, sources }: CellDetailProps) { aria-label="Copy value" > {copied - ? <>Copied - : <>Copy + ? <>Copied + : <>Copy }
@@ -168,7 +196,7 @@ export function CellDetail({ column, value, sources }: CellDetailProps) { className="flex items-start gap-1.5 text-xs text-link hover:underline break-all" data-ph-mask-text="true" > - + {src} diff --git a/frontend/components/table/DataRow.tsx b/frontend/components/table/DataRow.tsx index 0927963..a89058e 100644 --- a/frontend/components/table/DataRow.tsx +++ b/frontend/components/table/DataRow.tsx @@ -4,8 +4,15 @@ import { memo, type CSSProperties } from "react"; import { areEqual } from "react-window"; import type { Row } from "@tanstack/react-table"; import type { DatasetRow, DatasetColumn } from "./types"; -import { Maximize2 } from "lucide-react"; import { CellValue } from "./CellValue"; + +function IconMaximize2() { + return ( + + ); +} import { floorWidth } from "./utils"; export interface DataRowData { @@ -134,7 +141,7 @@ function DataRowImpl({ className="absolute right-1 top-1/2 -translate-y-1/2 opacity-0 group-hover:opacity-100 p-0.5 rounded bg-foreground/5 hover:bg-foreground/10 text-muted hover:text-foreground transition-all" aria-label={`Expand ${col.name}`} > - + {isPending &&
}
From 247e88277d61d30217adef95a578cc1d6f414d46 Mon Sep 17 00:00:00 2001 From: MMeteorL Date: Mon, 1 Jun 2026 20:09:37 -0700 Subject: [PATCH 3/4] Fix perf regression, simplify state shape, remove dead CSS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance: onCellExpand was an inline arrow on , busting the itemData useMemo on every parent render and causing react-window to re-render all visible rows unnecessarily. Replaced with a useCallback (handleCellExpand) so the reference is stable. Simplification: cellDetail state now stores the resolved DatasetColumn object at click time instead of a columnName string. This eliminates the render-time columns.find() call and the IIFE pattern in JSX — the SideSheet children reduce to a plain conditional render. Cleanup: removed @keyframes slide-in and .animate-slide-in from globals.css — those were added for the side-sheet variant and are unused now that the UI is a centered modal. Co-Authored-By: Claude Sonnet 4.6 --- frontend/app/dataset/[id]/page.tsx | 34 ++++++++++++++++-------------- frontend/app/globals.css | 9 -------- frontend/components/SideSheet.tsx | 18 +++++----------- 3 files changed, 23 insertions(+), 38 deletions(-) diff --git a/frontend/app/dataset/[id]/page.tsx b/frontend/app/dataset/[id]/page.tsx index 69be02c..728f273 100644 --- a/frontend/app/dataset/[id]/page.tsx +++ b/frontend/app/dataset/[id]/page.tsx @@ -10,6 +10,7 @@ import type { Id } from "@/convex/_generated/dataModel"; import { DatasetTable } from "@/components/table"; import { useSelection } from "@/components/table/use-selection"; import { SideSheet, CellDetail } from "@/components/SideSheet"; +import type { DatasetColumn } from "@/components/table/types"; import { useTheme } from "@/components/ThemeToggle"; import { StatusBadge } from "@/components/dataset/StatusBadge"; import { downloadCSV, downloadXLSX } from "@/lib/export"; @@ -36,7 +37,7 @@ export default function DatasetPage() { const [confirmPopulate, setConfirmPopulate] = useState(false); const [savingRefreshCadence, setSavingRefreshCadence] = useState(false); const [cellDetail, setCellDetail] = useState<{ - columnName: string; + column: DatasetColumn; value: unknown; sources?: string[]; } | null>(null); @@ -86,6 +87,14 @@ export default function DatasetPage() { } }, [dataset, populating, getToken]); + const handleCellExpand = useCallback((columnName: string, value: unknown, rowId: string) => { + if (!dataset || !rows) return; + const col = dataset.columns.find((c) => c.name === columnName); + if (!col) return; + const row = rows.find((r) => r._id === rowId); + setCellDetail({ column: col, value, sources: row?.sources }); + }, [dataset, rows]); + const openedFired = useRef(null); const autoPopulateFired = useRef(null); useEffect(() => { @@ -332,24 +341,17 @@ export default function DatasetPage() { rows={rows} datasetId={datasetId} selection={selection} - onCellExpand={(columnName, value, rowId) => { - const row = rows.find((r) => r._id === rowId); - setCellDetail({ columnName, value, sources: row?.sources }); - }} + onCellExpand={handleCellExpand} /> setCellDetail(null)}> - {cellDetail && (() => { - const col = dataset.columns.find((c) => c.name === cellDetail.columnName); - if (!col) return null; - return ( - - ); - })()} + {cellDetail && ( + + )} {confirmPopulate && ( diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 9687dc2..10b5ef4 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -103,15 +103,6 @@ body { 100% { background-position: 200% 0; } } -@keyframes slide-in { - from { transform: translateX(100%); } - to { transform: translateX(0); } -} - -.animate-slide-in { - animation: slide-in 0.2s ease-out; -} - @keyframes cell-flash { 0% { background-color: rgba(16, 185, 129, 0.25); } 100% { background-color: transparent; } diff --git a/frontend/components/SideSheet.tsx b/frontend/components/SideSheet.tsx index 07b0329..b969b4a 100644 --- a/frontend/components/SideSheet.tsx +++ b/frontend/components/SideSheet.tsx @@ -82,16 +82,16 @@ export function SideSheet({ open, onClose, children }: SideSheetProps) { if (!open) return null; return ( -
+
{/* Backdrop */}
- {/* Panel */} + {/* Modal */}

Cell Detail

@@ -144,19 +144,11 @@ export function CellDetail({ column, value, sources }: CellDetailProps) { )}
- {/* Type */} -
-

- Type -

-

{column.type}

-
- {/* Value */}

- Value + Value ({column.type})