Back to Langfuse

Peek View Table State Management

web/src/components/table/peek/README.md

3.200.015.2 KB
Original Source

Peek View Table State Management

This document describes how table state (filters, sorting, pagination, search) is managed and persisted in peek views when navigating between items using K/J keyboard shortcuts.

Overview

Peek views allow users to quickly preview table items in a side panel. When navigating between items using K/J shortcuts, tables inside the peek view remount, which would normally reset their state. The peek state management system prevents this by storing table state in a context that persists across navigation.

Panel shell, resize, and expand

TablePeekView (../peek.tsx) is responsive and keeps the peek on top of the table (it does not split the layout):

  • Desktop — a docked-right, non-modal Radix dialog (no overlay), so the table behind stays interactive. A left-edge handle resizes it; dragging to the far edge (or the header Expand button) expands it to the max width (viewport − sidebar; the sidebar stays visible).
  • Mobile (useIsMobile, <768px) — a vaul bottom drawer with native swipe-down dismissal (Expand is hidden).

Dismissal:

  • Click-outside closes, with exceptions: a target inside the peek ([data-peek-content]) never closes it; clicking another table row ([data-row-index]) switches the peeked item in place; and selection checkboxes / data-ignore-outside-interaction regions / the table's ignoredSelectors don't close it. Nested Radix popovers/menus opened inside the peek don't close it (DismissableLayer stacking).
  • Escape, the close button, and (mobile) swipe-down also close.

Panel state follows the local-feature-state pattern from frontend-large-feature-architecture:

  • store/peekPanelStore.ts — a per-mount vanilla Zustand store (lazy useState) owning ONLY the widget width + transient drag state, mutated through named actions. Selectors (selectWidgetWidth, selectIsResizing, selectDraftExpanded) return primitives so subscriptions bail out cheaply.
  • actions/resizePeekPanel.ts — the drag workflow (window pointer listeners → store actions; on pointer-up commits a widget width or flips the expanded flag). Takes the store instance, not hooks.
  • usePeekPanelState.ts — the integration boundary: owns the store, derives the final width (widget vs expanded — measuring the sidebar offset for the expanded case), and wires drag/keyboard to the store + action. Takes isExpanded in / onExpandedChange out (the URL owns expanded).

State altitudes:

StateAltitudeWhere it lives
expandedroute (shareable, reloadable)URL peekView=expanded, managed in peek.tsx (router.replace), cleared on close by usePeekNavigation
widthcross-view persisted preferencestore widthFraction, mirrored to localStorage (peekViewWidthFraction)
resize draghigh-frequency transientstore draftFraction / draftExpanded / isResizing, committed on pointer-up
which itemroutethe peek URL param (see below)

The peek and the standalone trace page already share one beta-aware fetch (../../trace/useTraceDetailData.ts), one body + title (../../trace/TraceDetailBody.tsxTraceDetailBody / traceDetailTitle), and one action set (../../trace/TraceDetailActions.tsx — star / publish / delete) — usePeekData is now a thin wrapper over the shared hook. Next slice: collapse the <Trace context> branching and fold these primitives into a single TraceDetailSurface wrapper so the peek and TracePage share one component, not four — see the PR discussion.

Architecture

The peek state architecture separates the persisting context provider from the remounting content:

┌─────────────────────────────────────────────────────────────┐
│ TablePeekView (peek.tsx)                                    │
│                                                             │
│  <PeekTableStateProvider>  ← Persists across itemId changes│
│    <div key={itemId}>      ← Only this remounts            │
│      {children}            ← Tables remount here           │
│    </div>                                                   │
│  </PeekTableStateProvider>                                  │
└─────────────────────────────────────────────────────────────┘
                            │
                            ▼
        ┌───────────────────────────────────────┐
        │ Table Components (e.g., ScoresTable)  │
        │                                       │
        │  Hooks automatically detect peek:     │
        │  • useOrderByState()                  │
        │  • usePaginationState()               │
        │  • useFullTextSearch()                │
        │                                       │
        │  Hooks requiring explicit wiring:     │
        │  • useSidebarFilterState()            │
        └───────────────────────────────────────┘
                            │
                            ▼
        ┌────────────────────────────────────────┐
        │ Hook reads from peek context:          │
        │                                        │
        │ const peekContext = usePeekTableState()│
        │ if (peekContext) {                     │
        │   useSidebarFilterState({             │
        │     stateLocation: "peekContext",     │
        │     context: peekContext,             │
        │   })                                  │
        │   return peekContext.tableState.X      │
        │ }                                      │
        │ return urlState                        │
        └────────────────────────────────────────┘

Key Components

PeekTableStateProvider

Location: contexts/PeekTableStateContext.tsx

  • Provides persistent state storage across peek item navigation
  • Stores filters, sorting, pagination, and search state
  • Does NOT remount when itemId changes during K/J navigation
  • Tables inside the peek view can read from this context when the relevant hook supports it or the caller explicitly wires it

Peek-Aware Hooks

Most state management hooks automatically detect when they're running inside a peek view and read/write state accordingly. useSidebarFilterState is the exception and must be wired explicitly by the caller.

  1. useSidebarFilterState - Manages filter state

    • Location: web/src/features/filters/hooks/useSidebarFilterState.tsx
    • Requires explicit hookOptions wiring: stateLocation: "peekContext" with context: usePeekTableState()
    • Without that wiring, it uses URL or session storage state instead of the peek context
  2. useOrderByState - Manages sorting state

    • Location: web/src/features/orderBy/hooks/useOrderByState.ts
    • Returns peek context state or URL state based on context availability
  3. usePaginationState - Manages pagination state

    • Location: web/src/hooks/usePaginationState.ts
    • Supports both page/limit and pageIndex/pageSize formats
    • Automatically detects peek context and uses it if available
  4. useFullTextSearch - Manages search query state

    • Location: web/src/components/table/use-cases/useFullTextSearch.tsx
    • Handles both search query and search type state

Behavior

✅ Expected Behavior (Fully Integrated Tables)

When using K/J navigation in peek view for fully integrated tables:

  1. User applies filter/sort/pagination/search
  2. State is stored in PeekTableStateContext
  3. User presses K/J → itemId changes → peek content remounts
  4. PeekTableStateProvider does NOT remount
  5. Remounted table hooks read from the same context instance
  6. Table automatically restores previous state

Fully Integrated Tables:

  • Traces table
  • Observations table
  • Scores table
  • Evaluators table
  • Events table
  • Eval Templates table (search now peek-aware)

🔄 Future-Proofed Tables (Peek-Aware Hooks Added)

These tables don't currently have peek views but now use peek-aware hooks, making them ready if peek views are added:

  • Sessions table
  • Models table
  • Score Configs table

Table State Interface

The PeekTableState interface defines what state is persisted:

typescript
interface PeekTableState {
  filters: FilterState;
  sorting: OrderByState;
  pagination: { pageIndex: number; pageSize: number };
  search: { query: string | null; type: string[] };
}

Implementation Guide

Adding Peek Views to a New Table

To make a table work with peek state persistence:

  1. Use peek-aware hooks instead of direct URL state management:

    typescript
    // ❌ Don't use useQueryParams directly
    const [paginationState, setPaginationState] = useQueryParams({
      pageIndex: withDefault(NumberParam, 0),
      pageSize: withDefault(NumberParam, 50),
    });
    
    // ✅ Use usePaginationState (automatically peek-aware)
    const [paginationState, setPaginationState] = usePaginationState(0, 50);
    
  2. For search, use useFullTextSearch:

    typescript
    // ❌ Don't use useQueryParam directly
    const [searchQuery, setSearchQuery] = useQueryParam("search", StringParam);
    
    // ✅ Use useFullTextSearch (automatically peek-aware)
    const { searchQuery, setSearchQuery } = useFullTextSearch();
    
  3. For filters, use useSidebarFilterState:

    typescript
    const peekContext = usePeekTableState();
    
    const queryFilterOptions: UseSidebarFilterStateOptions = useMemo(() => {
      if (peekContext) {
        return {
          loading: isSidebarFilterLoading,
          implicitDefaultConfig: DEFAULT_SIDEBAR_IMPLICIT_ENVIRONMENT_CONFIG,
          stateLocation: "peekContext",
          context: peekContext,
        };
      }
    
      return {
        loading: isSidebarFilterLoading,
        implicitDefaultConfig: DEFAULT_SIDEBAR_IMPLICIT_ENVIRONMENT_CONFIG,
        stateLocation: "urlAndSessionStorage",
        sessionFilterContextId: projectId,
      };
    }, [isSidebarFilterLoading, peekContext, projectId]);
    
    const queryFilter = useSidebarFilterState(
      filterConfig,
      filterOptions,
      queryFilterOptions,
    );
    

    useSidebarFilterState no longer detects peek context internally. If the table can render inside PeekTableStateProvider, the caller must pass stateLocation: "peekContext" explicitly or filters will persist in URL or session state instead of the in-memory peek state.

  4. For sorting, use useOrderByState:

    typescript
    // Already peek-aware, no changes needed if already using this hook
    const [orderByState, setOrderByState] = useOrderByState({
      column: "createdAt",
      order: "DESC",
    });
    

How It Works Internally

Most peek-aware hooks follow this pattern internally:

typescript
export const useSomeState = () => {
  const peekContext = usePeekTableState();

  // URL-based state (fallback)
  const [urlState, setUrlState] = useQueryParam(...);

  if (peekContext) {
    // In peek view: read/write from context
    const value = peekContext.tableState.someProperty;
    const setValue = (newValue) => {
      peekContext.setTableState({
        ...peekContext.tableState,
        someProperty: newValue,
      });
    };
    return { value, setValue };
  }

  // Not in peek view: use URL state
  return { value: urlState, setValue: setUrlState };
};

State Lifecycle and Caveats

When State Persists (Intended ✓)

Table state persists during K/J keyboard navigation within the same peek view:

1. Open trace T1 → apply filter to ScoresTable
2. Press K/J → navigate to trace T2
3. ScoresTable in T2 retains the filter ✓

Reason: PeekTableStateProvider remains mounted during K/J navigation. Only the content with key={itemId} remounts, preserving user's filter/sort/pagination preferences across items of the same type.

When State Resets (Safe ✓)

Table state resets when the peek view closes:

1. Open trace T1 → apply filter
2. Close peek (X button/Escape/click outside)
3. Open observation O1 → fresh state ✓

Reason: Closing the peek removes the peek URL parameter. This causes the <Sheet> component to close and unmount <SheetContent>, which unmounts PeekTableStateProvider and destroys all state.

Known Risk: Multiple Tables in One Peek View

Risk Level: LOW (theoretical edge case)

Issue: All tables in the same peek view share a single PeekTableState object. If a peek view contains multiple independent paginated tables, they share pagination/filter/sort state.

Example:

Hypothetical: Trace peek with both Scores table AND Events table
→ Navigate to page 2 of Scores table
→ context.pagination = { pageIndex: 1, pageSize: 50 }
→ Events table also shows page 2 ❌

Current Reality:

  • Peek views typically contain one primary table (e.g., ScoresTable in trace details)
  • Multiple instances of the same table type (e.g., trace-level scores + observation-level scores) intentionally share state
  • Tables use disableUrlPersistence and scope data via props (traceId, observationId)

Future Solution (if multiple independent table types are needed): Any follow-up design for namespaced peek state still needs to keep the explicit useSidebarFilterState wiring pattern:

typescript
const queryFilterOptions: UseSidebarFilterStateOptions = peekContext
  ? {
      loading,
      stateLocation: "peekContext",
      context: peekContext,
    }
  : {
      loading,
      stateLocation: "urlAndSessionStorage",
      sessionFilterContextId: projectId,
    };

const filters = useSidebarFilterState(config, options, queryFilterOptions);