web/src/components/table/peek/README.md
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.
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.
TablePeekView (../peek.tsx) is responsive and keeps the peek
on top of the table (it does not split the layout):
useIsMobile, <768px) — a vaul bottom drawer with native
swipe-down dismissal (Expand is hidden).Dismissal:
[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).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:
| State | Altitude | Where it lives |
|---|---|---|
| expanded | route (shareable, reloadable) | URL peekView=expanded, managed in peek.tsx (router.replace), cleared on close by usePeekNavigation |
| width | cross-view persisted preference | store widthFraction, mirrored to localStorage (peekViewWidthFraction) |
| resize drag | high-frequency transient | store draftFraction / draftExpanded / isResizing, committed on pointer-up |
| which item | route | the 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.tsx →
TraceDetailBody / 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.
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 │
└────────────────────────────────────────┘
Location: contexts/PeekTableStateContext.tsx
itemId changes during K/J navigationMost 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.
useSidebarFilterState - Manages filter state
web/src/features/filters/hooks/useSidebarFilterState.tsxhookOptions wiring:
stateLocation: "peekContext" with context: usePeekTableState()useOrderByState - Manages sorting state
web/src/features/orderBy/hooks/useOrderByState.tsusePaginationState - Manages pagination state
web/src/hooks/usePaginationState.tspage/limit and pageIndex/pageSize formatsuseFullTextSearch - Manages search query state
web/src/components/table/use-cases/useFullTextSearch.tsxWhen using K/J navigation in peek view for fully integrated tables:
PeekTableStateContextitemId changes → peek content remountsPeekTableStateProvider does NOT remountFully Integrated Tables:
These tables don't currently have peek views but now use peek-aware hooks, making them ready if peek views are added:
The PeekTableState interface defines what state is persisted:
interface PeekTableState {
filters: FilterState;
sorting: OrderByState;
pagination: { pageIndex: number; pageSize: number };
search: { query: string | null; type: string[] };
}
To make a table work with peek state persistence:
Use peek-aware hooks instead of direct URL state management:
// ❌ 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);
For search, use useFullTextSearch:
// ❌ Don't use useQueryParam directly
const [searchQuery, setSearchQuery] = useQueryParam("search", StringParam);
// ✅ Use useFullTextSearch (automatically peek-aware)
const { searchQuery, setSearchQuery } = useFullTextSearch();
For filters, use useSidebarFilterState:
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.
For sorting, use useOrderByState:
// Already peek-aware, no changes needed if already using this hook
const [orderByState, setOrderByState] = useOrderByState({
column: "createdAt",
order: "DESC",
});
Most peek-aware hooks follow this pattern internally:
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 };
};
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.
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.
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:
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:
const queryFilterOptions: UseSidebarFilterStateOptions = peekContext
? {
loading,
stateLocation: "peekContext",
context: peekContext,
}
: {
loading,
stateLocation: "urlAndSessionStorage",
sessionFilterContextId: projectId,
};
const filters = useSidebarFilterState(config, options, queryFilterOptions);
contexts/PeekTableStateContext.tsxweb/src/hooks/usePaginationState.tsuse-cases/useFullTextSearch.tsxweb/src/features/filters/hooks/useSidebarFilterState.tsxweb/src/features/orderBy/hooks/useOrderByState.ts