From 136fdc23be7a5477c0033ec537695a2ace2cc804 Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sun, 4 Oct 2026 19:55:17 -0700 Subject: [PATCH] perf(ui): stop re-rendering the Lens setup preview on every keystroke The URL draft syncs from a small component after typing pauses, step summaries watch their own fields, and the preview table only re-renders when its rows or paging change. Co-Authored-By: Claude Opus 5.5 --- .../lens/setup/InvestigationSetup.tsx | 60 +++++++++++++------ .../lens/setup/MatchingActivityPreview.tsx | 27 ++++++--- .../src/components/lens/setup/SetupSteps.tsx | 4 +- .../src/components/lens/setup/setupRoute.ts | 7 ++- .../lens/setup/useMatchingActivity.ts | 36 +++++------ 5 files changed, 85 insertions(+), 49 deletions(-) diff --git a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx index a6e0344f12a..0c9bb89ddcf 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx @@ -1,7 +1,7 @@ "use client"; import { FormProvider, useWatch, type UseFormReturn } from "react-hook-form"; -import { useEffect, useState, type ReactNode } from "react"; +import { useEffect, useMemo, useState, type ReactNode } from "react"; import { ArrowLeft } from "lucide-react"; import { useZodForm } from "@/lib/forms/useZodForm"; import { Button } from "@/components/ui/button"; @@ -14,7 +14,8 @@ import { type InvestigationInput, type InvestigationOutput, } from "./investigationSchema"; -import { draftFromParams, useSetupDraftRoute, useSetupStepRoute } from "./setupRoute"; +import { DRAFT_FIELDS, draftFromParams, useSetupDraftRoute, useSetupStepRoute } from "./setupRoute"; +import { useDebouncedValue } from "./useDebouncedValue"; import { nextSetupStep, SetupStep, SetupSteps } from "./SetupSteps"; import { ScopeFields } from "./fields/ScopeFields"; import { SampleFields } from "./fields/SampleFields"; @@ -33,6 +34,8 @@ import { type Settings } from "../model/types"; type SetupMode = "new" | "edit" | "duplicate"; +const DRAFT_URL_DEBOUNCE_MS = 400; + const TITLES: Record = { new: "New investigation", edit: "Edit investigation", @@ -44,6 +47,20 @@ function saveLabelFor(mode: SetupMode, repeat: boolean): string { return repeat ? "Run and monitor" : "Run investigation"; } +function ActivitySummary() { + const [selection, manual] = useWatch({ + name: ["selection", "manualSelection"], + }); + return activitySummary(selection, manual); +} + +function CriteriaSummary() { + const [context, watching, questions] = useWatch({ + name: ["context", "watching", "questions"], + }); + return criteriaSummary({ context, watching, questions }); +} + function activitySummary(selection: InvestigationInput["selection"], manual: boolean): string { const span = `Last ${durationLabel(selection.lookback_hours ?? 24, "hours")}`; const picked = selection.execution_ids.length; @@ -74,18 +91,27 @@ export function InvestigationSetup(props: SetupProps) { defaultValues: mode === "new" ? draftFromParams(draft.params) : investigationDefaults(initial, mode), mode: "onChange", }); - const { saveDraft } = draft; - useEffect(() => { - if (mode !== "new") return; - return form.subscribe({ formState: { values: true }, callback: ({ values }) => saveDraft(values) }); - }, [form, mode, saveDraft]); return ( + {mode === "new" && } ); } +/** Writes the draft to the URL once typing pauses; only this empty component re-renders per keystroke. */ +function DraftUrlSync() { + const [name, selection, context, watching, questions, repeat, interval] = useWatch< + InvestigationInput, + typeof DRAFT_FIELDS + >({ name: DRAFT_FIELDS }); + const draft = { name, selection, context, watching, questions, repeat, interval }; + const { value } = useDebouncedValue(draft, DRAFT_URL_DEBOUNCE_MS); + const { saveDraft } = useSetupDraftRoute(); + useEffect(() => saveDraft(value), [value, saveDraft]); + return null; +} + function SetupEditor({ initial, mode, @@ -99,12 +125,13 @@ function SetupEditor({ const [error, setError] = useState(""); const [trace, setTrace] = useState(null); const { control, register, setValue, subscribe, trigger, formState } = form; - const [selectedModel, repeat, selection, manualSelection, context, watching, questions] = useWatch({ - control, - name: ["selectedModel", "repeat", "selection", "manualSelection", "context", "watching", "questions"], - }); + const [selectedModel, repeat] = useWatch({ control, name: ["selectedModel", "repeat"] }); const activity = useMatchingActivity(); - const traceRuns = activity.preview.page.executions.flatMap((run) => (run.summary ? [traceRefOf(run.summary)] : [])); + const { executions } = activity.preview.page; + const traceRuns = useMemo( + () => executions.flatMap((run) => (run.summary ? [traceRefOf(run.summary)] : [])), + [executions], + ); const model = selectedModel ?? analysis.defaultModel ?? ""; useEffect( () => @@ -169,12 +196,7 @@ function SetupEditor({ )}
- + }> @@ -185,7 +207,7 @@ function SetupEditor({ id="criteria" heading="Criteria" description="What the agent should do and what to watch for" - summary={criteriaSummary({ context, watching, questions })} + summary={} > diff --git a/ui/litellm-dashboard/src/components/lens/setup/MatchingActivityPreview.tsx b/ui/litellm-dashboard/src/components/lens/setup/MatchingActivityPreview.tsx index 6226951e43e..7b4caaccaec 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/MatchingActivityPreview.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/MatchingActivityPreview.tsx @@ -1,6 +1,6 @@ "use client"; -import { type ComponentProps } from "react"; +import { memo, useMemo, type ComponentProps } from "react"; import { RotateCw } from "lucide-react"; import { Button } from "@/components/ui/button"; import { cn } from "@/lib/cva.config"; @@ -11,11 +11,16 @@ import type { MatchingPreview, PreviewSelection } from "./useMatchingActivity"; const executionOf = (run: TraceSummary) => `${run.trace_ref}:${run.trace_id}`; -const runPicks = (selection: PreviewSelection): RunPicks => ({ - isPicked: (run) => selection.ids.includes(executionOf(run)), - toggle: (run, picked) => selection.toggle(executionOf(run), picked), +const runPicks = (ids: PreviewSelection["ids"], toggle: PreviewSelection["toggle"]): RunPicks => ({ + isPicked: (run) => ids.includes(executionOf(run)), + toggle: (run, picked) => toggle(executionOf(run), picked), }); +const noSetup = () => {}; + +/** The preview can hold hundreds of rows; it re-renders only when its rows or paging change, not on every keystroke. */ +const PreviewTable = memo(AgentTracesTable); + type PreviewFooterProps = ComponentProps<"div"> & Pick; /** Selection count and a way to undo manual picks; hidden while every match is simply going to be analyzed. */ @@ -59,7 +64,13 @@ export function MatchingActivityPreview({ className, ...props }: MatchingActivityPreviewProps) { - const runs = page.executions.flatMap((run) => (run.summary ? [run.summary] : [])); + const runs = useMemo(() => page.executions.flatMap((run) => (run.summary ? [run.summary] : [])), [page.executions]); + const pickedIds = selection?.ids; + const togglePick = selection?.toggle; + const picks = useMemo( + () => (pickedIds && togglePick ? runPicks(pickedIds, togglePick) : undefined), + [pickedIds, togglePick], + ); const shown = status.ready || status.stale; return (
0)) && (
- {}} - picks={selection ? runPicks(selection) : undefined} + onSetUpTracing={noSetup} + picks={picks} />
)} diff --git a/ui/litellm-dashboard/src/components/lens/setup/SetupSteps.tsx b/ui/litellm-dashboard/src/components/lens/setup/SetupSteps.tsx index d75032dc63f..eaddb9deaae 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/SetupSteps.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/SetupSteps.tsx @@ -1,6 +1,6 @@ "use client"; -import { createContext, useContext, type ComponentProps } from "react"; +import { createContext, useContext, type ComponentProps, type ReactNode } from "react"; import { cn } from "@/lib/cva.config"; import { StepIndicator, type StepState } from "../ui/StepIndicator"; import { SETUP_STEPS, type SetupStep as SetupStepId } from "./investigationSchema"; @@ -45,7 +45,7 @@ export type SetupStepProps = ComponentProps<"li"> & { id: SetupStepId; heading: string; description: string; - summary: string; + summary: ReactNode; }; export function SetupStep({ id, heading, description, summary, className, children, ...props }: SetupStepProps) { diff --git a/ui/litellm-dashboard/src/components/lens/setup/setupRoute.ts b/ui/litellm-dashboard/src/components/lens/setup/setupRoute.ts index 9282f922a31..2a54f29f3d8 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/setupRoute.ts +++ b/ui/litellm-dashboard/src/components/lens/setup/setupRoute.ts @@ -38,6 +38,9 @@ export const SETUP_KEYS = [...Object.keys(SETUP_DRAFT_PARSERS), ...Object.keys(S export type SetupDraftParams = inferParserType; +export const DRAFT_FIELDS = ["name", "selection", "context", "watching", "questions", "repeat", "interval"] as const; +export type DraftFields = Pick; + const NEW_DRAFT = investigationDefaults(undefined, "new"); const knownWatch = new Set(watches.map((watch) => watch.id)); const sameList = (a: readonly string[], b: readonly string[]) => @@ -65,7 +68,7 @@ export function draftFromParams(params: SetupDraftParams): InvestigationInput { } /** Only what differs from a blank draft reaches the URL, so an untouched setup keeps a short link. */ -export function paramsFromDraft(draft: InvestigationInput): SetupDraftParams { +export function paramsFromDraft(draft: DraftFields): SetupDraftParams { const { selection } = draft; const checks = draft.questions.map((check) => check.instruction).filter((instruction) => instruction.trim()); const finite = (value: number | null) => (value != null && Number.isFinite(value) ? value : null); @@ -85,7 +88,7 @@ export function paramsFromDraft(draft: InvestigationInput): SetupDraftParams { export function useSetupDraftRoute() { const [params, setParams] = useQueryStates(SETUP_DRAFT_PARSERS, { history: "replace" }); - const saveDraft = useCallback((draft: InvestigationInput) => void setParams(paramsFromDraft(draft)), [setParams]); + const saveDraft = useCallback((draft: DraftFields) => void setParams(paramsFromDraft(draft)), [setParams]); return { params, saveDraft }; } diff --git a/ui/litellm-dashboard/src/components/lens/setup/useMatchingActivity.ts b/ui/litellm-dashboard/src/components/lens/setup/useMatchingActivity.ts index ce337d84f39..661929df086 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/useMatchingActivity.ts +++ b/ui/litellm-dashboard/src/components/lens/setup/useMatchingActivity.ts @@ -1,6 +1,6 @@ "use client"; -import { useCallback, useEffect, useState } from "react"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query"; import { useFormContext, useWatch } from "react-hook-form"; import { lensKeys, lensQueries } from "../data/queries"; @@ -95,16 +95,6 @@ function manualSelectedCount(selection: Selection): number { return Math.min(sampled, selection.sample_size ?? Infinity); } -function manualPicks(selection: Selection, setExecutionIds: (ids: readonly string[]) => void): PreviewSelection { - const ids = selection.execution_ids; - return { - ids, - count: manualSelectedCount(selection), - toggle: (id, checked) => setExecutionIds(checked ? [...ids, id] : ids.filter((other) => other !== id)), - clear: () => setExecutionIds([]), - }; -} - function windowLabel(selection: Selection): string { if (!validWindow(selection)) return "Choose a valid history window"; return `Last ${durationLabel(selection.lookback_hours ?? 24, "hours")}`; @@ -135,7 +125,7 @@ export function useMatchingActivity(): MatchingActivity { const valid = windowValid && validScope(scope); const preview = useInfiniteQuery(lensQueries.preview(api, { scope, asOf, enabled: valid })); const firstPage = preview.data?.pages[0]; - const executions = preview.data?.pages.flatMap((page) => page.executions) ?? []; + const executions = useMemo(() => preview.data?.pages.flatMap((page) => page.executions) ?? [], [preview.data]); const empty = firstPage?.eligible === 0; const refresh = useCallback(() => { setRefreshedAt(new Date().toISOString()); @@ -150,9 +140,21 @@ export function useMatchingActivity(): MatchingActivity { const ready = !pending && valid; const loading = valid && pending && !firstPage; const stale = valid && pending && !!firstPage; - const setExecutionIds = (next: readonly string[]) => - setValue("selection.execution_ids", [...next], { shouldValidate: true }); - const picked = manualSelection ? manualPicks(selection, setExecutionIds) : null; + const ids = selection.execution_ids; + const setExecutionIds = useCallback( + (next: readonly string[]) => setValue("selection.execution_ids", [...next], { shouldValidate: true }), + [setValue], + ); + const toggle = useCallback( + (id: string, checked: boolean) => setExecutionIds(checked ? [...ids, id] : ids.filter((other) => other !== id)), + [ids, setExecutionIds], + ); + const clear = useCallback(() => setExecutionIds([]), [setExecutionIds]); + const { hasNextPage, isFetching, fetchNextPage } = preview; + const loadMore = useCallback(() => { + if (hasNextPage && !isFetching) void fetchNextPage({ cancelRefetch: false }); + }, [hasNextPage, isFetching, fetchNextPage]); + const picked = manualSelection ? { ids, count: manualSelectedCount(selection), toggle, clear } : null; const hasMatches = !preview.error && (firstPage?.selected ?? 0) > 0; const hasSelection = !picked || picked.ids.length > 0; return { @@ -173,9 +175,7 @@ export function useMatchingActivity(): MatchingActivity { executions, hasMore: preview.hasNextPage, loadingMore: preview.isFetchingNextPage, - loadMore: () => { - if (preview.hasNextPage && !preview.isFetching) void preview.fetchNextPage({ cancelRefetch: false }); - }, + loadMore, }, selection: picked, },