mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-08 03:08:45 +00:00
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 <noreply@anthropic.com>
This commit is contained in:
parent
887c115e3a
commit
136fdc23be
5 changed files with 85 additions and 49 deletions
|
|
@ -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<SetupMode, string> = {
|
||||
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<InvestigationInput, ["selection", "manualSelection"]>({
|
||||
name: ["selection", "manualSelection"],
|
||||
});
|
||||
return activitySummary(selection, manual);
|
||||
}
|
||||
|
||||
function CriteriaSummary() {
|
||||
const [context, watching, questions] = useWatch<InvestigationInput, ["context", "watching", "questions"]>({
|
||||
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 (
|
||||
<FormProvider {...form}>
|
||||
{mode === "new" && <DraftUrlSync />}
|
||||
<SetupEditor {...props} form={form} />
|
||||
</FormProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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<TraceRef | null>(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({
|
|||
)}
|
||||
<div className="grid min-w-0 flex-1 items-start gap-8 pt-6 lg:grid-cols-[minmax(0,26rem)_minmax(0,1fr)] xl:gap-10">
|
||||
<SetupSteps aria-label="Investigation setup" current={step} onOpen={setStep}>
|
||||
<SetupStep
|
||||
id="activity"
|
||||
heading="Activity"
|
||||
description="Which runs to review"
|
||||
summary={activitySummary(selection, manualSelection)}
|
||||
>
|
||||
<SetupStep id="activity" heading="Activity" description="Which runs to review" summary={<ActivitySummary />}>
|
||||
<ScopeFields {...activity.scope} />
|
||||
<SampleFields eligible={activity.preview.page.eligible} />
|
||||
<StepFooter>
|
||||
|
|
@ -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={<CriteriaSummary />}
|
||||
>
|
||||
<ExpectationsFields />
|
||||
<StepFooter>
|
||||
|
|
|
|||
|
|
@ -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<MatchingPreview, "page" | "selection">;
|
||||
|
||||
/** 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 (
|
||||
<section
|
||||
|
|
@ -104,7 +115,7 @@ export function MatchingActivityPreview({
|
|||
)}
|
||||
{(status.loading || (shown && !status.error && runs.length > 0)) && (
|
||||
<div className="max-h-[calc(100dvh-16rem)] min-h-0 overflow-auto">
|
||||
<AgentTracesTable
|
||||
<PreviewTable
|
||||
traces={runs}
|
||||
isLoading={status.loading}
|
||||
error={null}
|
||||
|
|
@ -112,8 +123,8 @@ export function MatchingActivityPreview({
|
|||
isFetching={page.loadingMore}
|
||||
isPlaceholder={status.stale}
|
||||
onLoadMore={page.loadMore}
|
||||
onSetUpTracing={() => {}}
|
||||
picks={selection ? runPicks(selection) : undefined}
|
||||
onSetUpTracing={noSetup}
|
||||
picks={picks}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -38,6 +38,9 @@ export const SETUP_KEYS = [...Object.keys(SETUP_DRAFT_PARSERS), ...Object.keys(S
|
|||
|
||||
export type SetupDraftParams = inferParserType<typeof SETUP_DRAFT_PARSERS>;
|
||||
|
||||
export const DRAFT_FIELDS = ["name", "selection", "context", "watching", "questions", "repeat", "interval"] as const;
|
||||
export type DraftFields = Pick<InvestigationInput, (typeof DRAFT_FIELDS)[number]>;
|
||||
|
||||
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 };
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue