Langfuse Peek 视图表格状态管理:K/J 导航下的过滤器、排序、分页与搜索持久化机制
【免费下载链接】langfuse🪢 Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. 🍊YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse
导读
Peek 视图是 Langfuse 数据表格(Traces、Observations、Scores 等)侧边栏内的快速预览面板:用户按 K/J 键盘快捷键在相邻条目间跳转时,面板内嵌套的表格组件会因itemId变化而整体重挂载(remount),若不加处理,表格的筛选、排序、分页与搜索状态会全部丢失。本文以 peek/README.md 为骨架,结合 peek.tsx、PeekTableStateContext.tsx 及各 peek-aware Hook 的源码实现,完整讲解该状态管理系统的设计动机、面板外壳(尺寸/展开/关闭)、架构分层、PeekTableState数据结构、接入新表格的完整步骤,以及状态生命周期与已知边界风险。读完本文,你将能独立为 Langfuse 任意数据表格接入持久的 Peek 状态,并理解其背后的"Provider 不重挂载、内容按 key 重挂载"核心模式。
一、为什么需要 Peek 状态管理:K/J 导航与重挂载问题
Langfuse 的 Peek 视图允许用户在侧边面板中快速预览表格条目。当用户使用 K/J 快捷键在条目之间导航时,peekURL 参数(即itemId)会变化,TablePeekView内部以key={itemId}标识的内容区块随之重挂载:
// web/src/components/table/peek.tsx 中的核心结构 <PeekTableStateProvider> {/* ← 跨 itemId 变化保持挂载 */} <div className="flex-1 overflow-auto" key={itemId}> {children} {/* ← 只有这里会重挂载 */} </div> </PeekTableStateProvider>如果表格状态直接存放在组件本地或 URL query 中,每次重挂载都会使过滤器、排序、分页、搜索全部归零。Peek 状态管理系统通过将表格状态提升到PeekTableStateProvider提供的 Context 中,让状态实例在导航期间保持不变,从而解决这一问题。
从源码可以看到该 Provider 的初始状态定义(PeekTableStateContext.tsx):
const [tableState, setTableState] = useState<PeekTableState>({ filters: [], sorting: undefined, pagination: { pageIndex: 0, pageSize: 50 }, search: { query: null, type: ["id"] }, });二、面板外壳:尺寸、展开与关闭
TablePeekView(peek.tsx)是响应式的,且始终保持 Peek悬浮在表格之上(不拆分布局)。
2.1 桌面端:右侧停靠的非模态 Dialog
- 桌面端使用非模态(
modal={false})的 Radix Dialog(Sheet),没有遮罩层,表格仍可交互; - 面板左缘有一个可拖拽的 resize 手柄(位于
absolute inset-y-0 -left-1,横跨面板左边缘,左右两侧均可抓取); - 拖到最右边缘(或点击头部 Expand 按钮)会展开到最大宽度(视口宽度 − 侧边栏宽度,侧边栏保持可见)。
展开状态由 URL 参数peekView=expanded持有(可分享、可刷新恢复),在peek.tsx中通过router.replace(shallow)写入,关闭时由usePeekNavigation清理。替换而非压栈(replace 而非 push)是为了避免展开/收起切换刷爆浏览器历史。
2.2 手持端:vaul 底部抽屉
当useIsHandheld判定为手持设备时(宽度小于md或在矮屏上使用粗指针设备——例如横屏手机),Peek 渲染为vaul底部抽屉,支持原生下滑手势关闭,此时隐藏 Expand 按钮。值得注意的是该判定不是只按宽度:横屏手机宽度超过md,但仍应得到抽屉而非桌面 Sheet,这正是引入useIsHandheld的原因。
Provider 同时包裹移动端与桌面端两种外壳(位于isHandheld分支之上),因此当用户跨断点调整窗口大小时,抽屉↔Sheet 的切换不会重挂载 Provider,已持有的表格状态得以保留——只有关闭 Peek(return null)才会卸载 Provider 并重置状态。
2.3 关闭行为与例外规则
关闭途径包括:点击外部、Escape 键、关闭按钮、移动端下滑。但点击外部关闭存在一系列精心设计的例外(shouldKeepPeekOpenOnOutsideInteraction):
| 场景 | 行为 | 依据 |
|---|---|---|
点击 Peek 内部([data-peek-content]) | 不关闭 | 防内部分割条手柄被 Radix 误报为外部 |
点击另一表格行([data-row-index]) | 就地切换peek 条目,不关闭 | 行的自有点击处理器负责 |
勾选选择复选框([role="checkbox"]) | 不关闭 | 全局常量ALWAYS_KEEP_PEEK_OPEN_SELECTORS |
data-ignore-outside-interaction区域 | 不关闭 | 复用 outside-interaction 工具 |
Toast 层覆盖([data-layer="toast"],如版本更新横幅) | 不关闭 | 关闭 Toast 不等于关闭 Peek |
表格自定义ignoredSelectors | 不关闭 | 由表格透传,保护行内操作按钮 |
此外,Peek 内打开的嵌套 Radix Popover/Menu 不会关闭 Peek(依赖 RadixDismissableLayer的层级堆叠机制),且onFocusOutside被显式preventDefault()——焦点移出(例如焦点进入 portal 化的 Popover)不会触发关闭,只有指针、Escape 或关闭按钮驱动关闭。
2.4 宽度状态的三层"海拔"
面板状态遵循frontend-large-feature-architecture的 local-feature-state 模式,将不同频率的状态放在不同海拔:
| 状态 | 海拔 | 存放位置 |
|---|---|---|
expanded(展开) | 路由级(可分享、可刷新) | URLpeekView=expanded,peek.tsx 管理,关闭时由usePeekNavigation清理 |
width(宽度) | 跨视图持久化偏好 | storewidthFraction,镜像到localStorage(key 为peekViewWidthFraction) |
| resize 拖拽 | 高频瞬态 | storedraftFraction/draftExpanded/isResizing,pointer-up 时提交 |
| 当前条目(item) | 路由级 | peekURL 参数 |
- store/peekPanelStore.ts:每次挂载创建一个 vanilla Zustand store(懒
useState持有),只管理面板宽度 + 瞬态拖拽状态,通过具名actions变更;selectWidgetWidth、selectIsResizing、selectDraftExpanded等选择器返回原始值,让订阅可以廉价地 bail out(selectWidgetWidth返回"50vw"这样的 CSS 字符串,宽度不变就不触发重渲染)。 - actions/resizePeekPanel.ts:完整拖拽工作流(window 级 pointer 监听 → store actions;pointer-up 时提交宽度或翻转展开标志)。它不是 Hook,直接接收 store 实例。值得注意的实现细节:
pointercancel被视为abort(系统手势抢占、掌托误触、无障碍工具、pointer-capture 转移等),只清理而不提交,防止被取消的手势覆盖已持久化的宽度;- 拖过
expandAtFraction(侧边栏边缘 =viewport − sidebar,动态传入)就进入 expanded 预览;startExpanded参数保证"按下手柄"本身不改变宽度,只有移动指针才生效,避免展开态下按下即回跳。
- usePeekPanelState.ts:集成边界。持有 store、推导最终宽度(widget vs expanded——展开时实测侧边栏偏移)、把拖拽/键盘事件接到 store 与 action。
pendingExpanded桥接异步间隙:拖拽/按钮提交新 expanded 值后先本地持有,直到 URL(isExpanded)追上,避免松手后一帧旧宽度闪烁。侧边栏偏移通过ResizeObserver持续跟踪(不止展开时才测量),保证下次展开无陈旧偏移闪烁。键盘支持方向键微调(左键放大、右键缩小,步长 5%,即KEYBOARD_RESIZE_STEP = 0.05),连续微调以 1 秒防抖合并为一次onResized通知。
2.5 默认宽度:视口感知的 px 上限
默认宽度(无保存偏好时)为 50vw,但用像素上限PEEK_MAX_DEFAULT_WIDTH_PX = 1400封顶(对应 LFE-10601):普通笔记本仍按 50vw 打开,只有超宽显示器会压缩比例,保证 Peek 舒适且底层列表仍可导航。相关常量:
// web/src/components/table/peek/store/peekPanelStore.ts export const PEEK_MIN_WIDTH_FRACTION = 0.4; // 最小 40vw export const PEEK_MAX_WIDGET_WIDTH_FRACTION = 0.9; // 最大 90vw export const PEEK_DEFAULT_WIDTH_FRACTION = 0.5; // 默认 50vw export const PEEK_EXPAND_ENTER_FRACTION = 0.95; // 拖过 95vw 预览展开 export const PEEK_MAX_DEFAULT_WIDTH_PX = 1400; // 默认宽度 px 上限resolveDefaultWidthFraction计算max(MIN, min(0.5, 1400 / vw)),SSR 安全(无 window 时返回纯比例)。
2.6 面板内部 tree↔info 分割
Peek 内部的树/信息分割是独立的react-resizable-panels分组(TraceLayoutDesktop),与面板宽度统一,持久化在localStorage中,使用 peek 作用域的分组 id(trace-layout-peek-*),而非按标签页隔离的sessionStorage。其默认值以显式百分比计算(而非库默认的 pxdefaultSize——那会在中间态宽度上解析,导致默认值不确定):信息面板获得舒适的目标占比,树/时间线取剩余部分并被夹在舒适区间内——因此大屏 Peek 的额外宽度流向 info(内容),而非 tree(索引)。全页 Trace 视图则保留自己按比例共享、按标签页隔离的布局。
2.7 与全页 Trace 页面的共享
Peek 与独立 Trace 页面已经共享一套 beta 感知的数据获取 Hook useTraceDetailData.ts、一个 body + 标题(TraceDetailBody.tsx 与 traceDetailTitle.ts)、以及同一套操作集(TraceDetailActions.tsx——star / publish / delete),usePeekData现在只是共享 Hook 的薄封装。README 还预告了下一步重构方向:把<Trace context>的分支折叠进单一TraceDetailSurface包装器,让 Peek 与TracePage共享一个组件而非四个。
三、架构:持久化 Provider 与重挂载内容的分离
┌─────────────────────────────────────────────────────────────┐ │ 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 │ └────────────────────────────────────────┘3.1 PeekTableStateProvider
位置:contexts/PeekTableStateContext.tsx
- 在 K/J 导航期间提供持久的状态存储;
- 存储 filters、sorting、pagination、search;
- 当
itemId在 K/J 导航中变化时不重挂载; - Peek 内的表格可在 Hook 支持(或调用方显式接线)时从该 Context 读取状态。
Context 值结构为{ tableState, setTableState },其中setTableState是标准的Dispatch<SetStateAction<PeekTableState>>,value 经useMemo缓存避免无谓重渲染;usePeekTableState()即useContext(PeekTableStateContext)。
3.2 Peek-Aware Hooks
大多数状态管理 Hook 会自动检测自身是否运行在 Peek 视图内,并相应地读写状态;useSidebarFilterState是例外,必须由调用方显式接线。
1.useSidebarFilterState(过滤器)
- 位置:web/src/features/filters/hooks/useSidebarFilterState.tsx
- 需要显式
hookOptions接线:stateLocation: "peekContext"并传入context: usePeekTableState(); - 不接线则使用 URL 或 session storage 状态,而非 peek context。
从源码看,UseSidebarFilterStateOptions是一个判别联合类型(useSidebarFilterState.tsx):peekContext分支要求context: PeekTableStateContextValue,urlAndSessionStorage分支支持sessionFilterContextId防跨上下文串味,另有url与memory两种模式;默认值为{ stateLocation: "urlAndSessionStorage" }。Hook 内部通过stateLocationType决定取hookOptions.context还是 session storage,并在 URL 编码超过MAX_URL_FILTER_QUERY_LENGTH(约 16KB,规避 431)时回退到 session-storage 镜像。
2.useOrderByState(排序)
- 位置:web/src/features/orderBy/hooks/useOrderByState.ts
- 有 peek context 时返回 context 状态,否则返回 URL 状态。
源码关键点在排序的默认值语义:sorting === undefined时返回调用方传入的initialState(即"未显式排序=用默认排序"),一旦用户修改或禁用排序,该显式 peek-local 值才会被持久化(见setState写入sorting: newSorting的分支)。也就是说PeekTableState里sorting为undefined表示"沿用默认",这点在后续接口一节再次强调。
3.usePaginationState(分页)
- 位置:web/src/hooks/usePaginationState.ts
- 同时支持
page/limit与pageIndex/pageSize两种格式(通过函数重载与paramNames参数切换); - 自动检测 peek context,存在时使用之。
源码实现(usePaginationState.ts):在 peek context 下,pageIndex/pageSize分支返回 TanStack 的PaginationState并支持函数式 updater;page/limit分支将 context 的 0 基pageIndex转成 1 基page。两种格式都只读peekContext.tableState.pagination,写入时做{...peekContext.tableState, pagination: newValue}的不可变更新。
4.useFullTextSearch(全文搜索)
- 位置:web/src/components/table/use-cases/useFullTextSearch.tsx
- 同时处理搜索 query 与搜索类型(
searchType,取值范围为TracingSearchType:"id" | "content" | "input" | "output")。
在非 peek 分支中有一个细节:当搜索类型回到默认作用域id(或空数组)时,会移除searchType参数而不是写出显式的?searchType=id,保证 URL 与保存视图与"无作用域"状态一致,无论变更来自搜索栏还是旧工具栏。
四、PeekTableState 接口
PeekTableState接口定义了被持久化的全部状态(PeekTableStateContext.tsx):
interface PeekTableState { filters: FilterState; sorting: OrderByState | undefined; pagination: { pageIndex: number; pageSize: number }; search: { query: string | null; type: string[] }; }字段语义与默认值:
| 字段 | 类型 | 初始值 | 说明 |
|---|---|---|---|
filters | FilterState(来自@langfuse/shared) | [] | 侧边栏过滤器集合 |
sorting | OrderByState \| undefined | undefined | undefined表示使用useOrderByState传入的默认排序;用户修改或禁用后才持久化显式值 |
pagination | { pageIndex: number; pageSize: number } | { pageIndex: 0, pageSize: 50 } | 0 基页码 + 每页条数 |
search | { query: string \| null; type: string[] } | { query: null, type: ["id"] } | 搜索词 + 搜索类型作用域(默认按 id 搜索) |
五、接入指南:如何为一个新表格启用 Peek 状态持久化
5.1 用 peek-aware Hook 替代直接 URL 状态管理
分页——不要直接使用useQueryParams:
// ❌ 直接管理 URL query const [paginationState, setPaginationState] = useQueryParams({ pageIndex: withDefault(NumberParam, 0), pageSize: withDefault(NumberParam, 50), }); // ✅ 使用 usePaginationState(自动 peek-aware) const [paginationState, setPaginationState] = usePaginationState(0, 50);搜索——使用useFullTextSearch:
// ❌ 直接管理 URL query const [searchQuery, setSearchQuery] = useQueryParam("search", StringParam); // ✅ 使用 useFullTextSearch(自动 peek-aware) const { searchQuery, setSearchQuery } = useFullTextSearch();过滤器——必须显式接线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不再在内部检测 peek context。只要表格可能渲染在PeekTableStateProvider内,调用方就必须显式传stateLocation: "peekContext"与context,否则过滤器会持久化到 URL 或 session 状态,而不是内存中的 peek 状态。
排序——useOrderByState已经 peek-aware,无需改动:
const [orderByState, setOrderByState] = useOrderByState({ column: "createdAt", order: "DESC", });5.2 内部工作原理:统一的"context 优先,URL 兜底"模式
大多数 peek-aware Hook 内部遵循同一模式:
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 }; };注意两点实现共性:其一,写入一律采用"展开 + 覆盖单字段"的不可变更新,保证不丢其他字段;其二,Hook 顶层先无条件调用 URL Hook(useQueryParam等),再按 context 决定返回值,从而保证 Hook 调用顺序在开/关 Peek 之间保持稳定,符合 React 规则。
六、状态生命周期与边界情况
6.1 何时持久化(预期行为 ✓)
表格状态在同一个 Peek 视图内进行 K/J 键盘导航时持续保留:
1. Open trace T1 → apply filter to ScoresTable 2. Press K/J → navigate to trace T2 3. ScoresTable in T2 retains the filter ✓原因:K/J 导航期间PeekTableStateProvider保持挂载,只有key={itemId}的内容重挂载,因此同一类型条目间用户的 filter/sort/pagination 偏好得以保留。
已完整集成的表格包括:Traces、Observations、Scores、Evaluators、Events,以及 Eval Templates(搜索现已 peek-aware)。
6.2 何时重置(安全行为 ✓)
表格状态在Peek 视图关闭时重置:
1. Open trace T1 → apply filter 2. Close peek (X button/Escape/click outside) 3. Open observation O1 → fresh state ✓原因:关闭 Peek 会移除peekURL 参数,触发<Sheet>关闭并卸载<SheetContent>,进而卸载PeekTableStateProvider,销毁全部状态(对应 peek.tsx 中if (!itemId || !mounted) return null;提前返回对 Provider 的卸载)。同一机制还解释了usePaginationState等 Hook 在 peek 分支外使用useQueryParams的兜底——关闭后回到常规 URL 状态。
6.3 已为未来预置的表格(Peek-Aware Hook 已就位)
以下表格目前没有 Peek 视图,但已改用 peek-aware Hook,未来若添加 Peek 视图即可直接工作:Sessions 表、Models 表、Score Configs 表。
6.4 已知风险:单个 Peek 视图内多张表格共享状态
风险等级:LOW(理论边界情况)
问题:同一 Peek 视图内的所有表格共享单一PeekTableState对象。若一个 Peek 视图包含多张相互独立的分页表格,它们会共享 pagination/filter/sort 状态。
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 ❌当前现实:
- Peek 视图通常只含一张主表格(例如 trace 详情中的 ScoresTable);
- 同一表格类型的多个实例(如 trace 级 scores + observation 级 scores)有意共享状态;
- 表格使用
disableUrlPersistence,并通过 props(traceId、observationId)作用域化数据。
未来方案(若确需多种独立表格类型):任何为 peek 状态做命名空间(namespaced)的后续设计,仍需保留显式的useSidebarFilterState接线模式:
const queryFilterOptions: UseSidebarFilterStateOptions = peekContext ? { loading, stateLocation: "peekContext", context: peekContext, } : { loading, stateLocation: "urlAndSessionStorage", sessionFilterContextId: projectId, }; const filters = useSidebarFilterState(config, options, queryFilterOptions);6.5 其他值得注意的生命周期细节
- 删除竞态:
shouldClosePeekAfterDelete(LFE-10535)——删除请求返回后,仅当 Peek当前仍显示被删除的 trace 时才关闭。若用户先删除 trace A,在 mutation 落定前又 K/J 跳到 trace B,则必须保留 B 的 Peek,否则 A 的陈旧closePeek回调会清掉(现在的)B 的 peek 参数。 - 挂载门控:首次渲染以
mounted状态门控,避免useIsHandheld解析完成前先绘制桌面 Sheet(移动端深链会闪错外壳)。 data-peek-content守卫:某些原生捕获指针的 primitive(如内部react-resizable-panels的分割手柄)可能绕过 Radix 的内部检测而被误报为外部,该守卫保证 Peek 内的交互不会在中途关闭并卸载面板分组。
七、相关文件速查
| 用途 | 路径 |
|---|---|
Peek 状态 Context(Provider +usePeekTableState) | contexts/PeekTableStateContext.tsx |
| Peek 视图外壳(桌面 Sheet / 移动 Drawer、点击外部规则) | peek.tsx |
| 面板宽度 store(vanilla Zustand) | store/peekPanelStore.ts |
| 拖拽 resize 工作流 | actions/resizePeekPanel.ts |
| 面板宽度集成边界 Hook | usePeekPanelState.ts |
| Peek 打开/关闭与 URL 参数管理 | hooks/usePeekNavigation.ts |
| 分页 Hook | web/src/hooks/usePaginationState.ts |
| 全文搜索 Hook | use-cases/useFullTextSearch.tsx |
| 过滤器 Hook | web/src/features/filters/hooks/useSidebarFilterState.tsx |
| 排序 Hook | web/src/features/orderBy/hooks/useOrderByState.ts |
| 共享数据获取(Peek 与全页共用) | web/src/features/traces/hooks/useTraceDetailData.ts |
| 共享 body / 标题 / 操作集 | TraceDetailBody.tsx、traceDetailTitle.ts、TraceDetailActions.tsx |
结语
Langfuse 的 Peek 状态管理系统用"Provider 持久 + 内容按 key 重挂载"这一简洁模式,解决了 K/J 快捷导航下表格状态丢失的体验问题,并把"四种状态(过滤器/排序/分页/搜索)+ 三档状态海拔(路由/持久化偏好/瞬态拖拽)"清晰拆解到各自归属的层。对开发者而言,接入新表格只需遵循"优先使用 peek-aware Hook、为useSidebarFilterState显式接线"两条铁律;对架构师而言,本地 store + 命名 action + 共享数据层折叠(TraceDetailSurface)的方向也提供了很好的参考。相关实现与测试(如 resizePeekPanel.clienttest.ts、peekPanelStore.clienttest.ts、outsideInteraction.clienttest.tsx)均在仓库web/src/components/table/peek/目录下,可进一步深入研读。
【免费下载链接】langfuse🪢 Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. 🍊YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考