From 61d65176bfb027faf6aae790cbde408cfea3c1bb Mon Sep 17 00:00:00 2001 From: Yujong Lee Date: Sun, 4 Oct 2026 19:44:15 -0700 Subject: [PATCH] feat(ui): polish Lens investigation setup and sync its draft to the URL The setup reads as one form: inline name, fused duration and unit inputs, a compact watch list, switches for scheduling, and a wider preview. A new investigation's filters, criteria and step live in the URL, and the Traces tab offers to investigate the runs a search shows. Co-Authored-By: Claude Opus 5.5 --- .../src/components/lens/LensWorkspace.tsx | 4 +- .../src/components/lens/route.test.tsx | 42 ++++++ .../src/components/lens/route.ts | 36 ++++- .../InvestigationSetup.integration.test.tsx | 66 +++++++-- .../lens/setup/InvestigationSetup.tsx | 109 +++++++------- .../lens/setup/MatchingActivityPreview.tsx | 52 +++---- .../src/components/lens/setup/SetupSteps.tsx | 33 +++-- .../src/components/lens/setup/WatchPicker.tsx | 135 ++++-------------- .../lens/setup/fields/AnalysisModelField.tsx | 4 +- .../lens/setup/fields/ExpectationsFields.tsx | 68 ++++----- .../lens/setup/fields/RunFields.tsx | 121 +++++++--------- .../lens/setup/fields/SampleFields.tsx | 86 +++++++---- .../lens/setup/fields/ScopeFields.tsx | 11 +- .../lens/setup/fields/SwitchRow.tsx | 34 +++++ .../lens/setup/investigationSchema.ts | 6 +- .../components/lens/setup/setupRoute.test.ts | 62 ++++++++ .../src/components/lens/setup/setupRoute.ts | 99 +++++++++++++ .../lens/setup/useMatchingActivity.ts | 2 +- .../lens/traces/list/AgentTracesPage.tsx | 4 + .../AgentTracesSection.integration.test.tsx | 21 +++ .../lens/traces/list/AgentTracesSection.tsx | 36 ++++- .../src/components/shared/DurationInput.tsx | 21 ++- 22 files changed, 689 insertions(+), 363 deletions(-) create mode 100644 ui/litellm-dashboard/src/components/lens/route.test.tsx create mode 100644 ui/litellm-dashboard/src/components/lens/setup/fields/SwitchRow.tsx create mode 100644 ui/litellm-dashboard/src/components/lens/setup/setupRoute.test.ts create mode 100644 ui/litellm-dashboard/src/components/lens/setup/setupRoute.ts diff --git a/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx b/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx index 6a815e2da75..c4af5daeea2 100644 --- a/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx +++ b/ui/litellm-dashboard/src/components/lens/LensWorkspace.tsx @@ -19,7 +19,7 @@ import { LensModeSwitch, type SetupBadge } from "./LensModeSwitch"; import { frameCard } from "./ui/frame"; import { investigationActivity, listPollInterval } from "./model/status"; import { cn } from "@/lib/cva.config"; -import { useDialogRoute, useLensRoute, type LensDialog, type LensTab } from "./route"; +import { useDialogRoute, useInvestigateRoute, useLensRoute, type LensDialog, type LensTab } from "./route"; import { LensIntroDialog, useLensIntro } from "./onboarding/LensIntroDialog"; import { OnboardingProvider, type Onboarding } from "./onboarding/OnboardingContext"; import { traceRefOf, useOpenTraceRouting, useTracingSetupRoute } from "@/components/lens/traces/routing"; @@ -86,6 +86,7 @@ function LensContent({ userRole, readOnly }: Omit const accessToken = useLensAccessToken(); const { tab, lensId, demo, settingUp, setTab, setDemo, setSetup } = useLensRoute(); const { dialog, openDialog } = useDialogRoute(); + const investigate = useInvestigateRoute(); const { openTrace } = useOpenTraceRouting(); const [connecting] = useTracingSetupRoute(); const [previewTarget, setPreviewTarget] = useState(null); @@ -185,6 +186,7 @@ function LensContent({ userRole, readOnly }: Omit isActive={activeTab === "traces"} readOnly={readOnly} canMintTracingKey={isAdmin} + onInvestigate={canViewInvestigations && isAdmin && !readOnly ? investigate : undefined} /> diff --git a/ui/litellm-dashboard/src/components/lens/route.test.tsx b/ui/litellm-dashboard/src/components/lens/route.test.tsx new file mode 100644 index 00000000000..1c7b8870f9f --- /dev/null +++ b/ui/litellm-dashboard/src/components/lens/route.test.tsx @@ -0,0 +1,42 @@ +import { act, renderHook, waitFor } from "@testing-library/react"; +import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/testing"; +import type { PropsWithChildren } from "react"; +import { describe, expect, it, vi } from "vitest"; + +import { useDialogRoute, useInvestigateRoute } from "./route"; + +const renderRoute = (hook: () => T, searchParams: string) => { + const onUrlUpdate = vi.fn(); + const wrapper = ({ children }: PropsWithChildren) => ( + + {children} + + ); + const view = renderHook(hook, { wrapper }); + const lastUrl = () => new URLSearchParams(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""); + return { ...view, lastUrl }; +}; + +describe("setup routes", () => { + it("opens a new investigation on the searched runs, dropping any older draft", async () => { + const { result, lastUrl } = renderRoute(useInvestigateRoute, "?tab=traces&q=old&context=stale&step=run"); + await act(async () => result.current({ q: "agent:support status:error", lookbackHours: 168 })); + await waitFor(() => expect(lastUrl().get("dialog")).toBe("new")); + expect(Object.fromEntries(lastUrl())).toEqual({ + tab: "investigations", + dialog: "new", + q: "agent:support status:error", + lookback: "168", + }); + }); + + it("closing setup clears the draft but keeps the run search the Traces tab shares", async () => { + const { result, lastUrl } = renderRoute( + useDialogRoute, + "?tab=investigations&dialog=new&q=agent:support&name=Refunds&lookback=72&watch=watch_unsafe&step=criteria", + ); + await act(async () => result.current.closeDialog()); + await waitFor(() => expect(lastUrl().has("dialog")).toBe(false)); + expect(Object.fromEntries(lastUrl())).toEqual({ tab: "investigations", q: "agent:support" }); + }); +}); diff --git a/ui/litellm-dashboard/src/components/lens/route.ts b/ui/litellm-dashboard/src/components/lens/route.ts index 63dbf65d9b4..c237643ab20 100644 --- a/ui/litellm-dashboard/src/components/lens/route.ts +++ b/ui/litellm-dashboard/src/components/lens/route.ts @@ -3,6 +3,7 @@ import { parseAsBoolean, parseAsString, parseAsStringLiteral, useQueryStates } from "nuqs"; import { useCallback } from "react"; import { OPEN_TRACE_PARSERS, RUN_FILTER_PARSERS, TRACING_SETUP_PARSERS } from "@/components/lens/traces/routing"; +import { SETUP_DRAFT_PARSERS, SETUP_KEYS, SETUP_STEP_PARSERS } from "@/components/lens/setup/setupRoute"; export const LENS_TABS = { traces: "Traces", investigations: "Investigations", settings: "Settings" } as const; export type LensTab = keyof typeof LENS_TABS; @@ -47,12 +48,15 @@ const SESSION_PARSERS = { ...LIST_PARSERS, ...RESULT_PARSERS, ...DIALOG_PARSERS, + ...SETUP_DRAFT_PARSERS, + ...SETUP_STEP_PARSERS, }; const nulls = (keys: readonly K[]) => Object.fromEntries(keys.map((key) => [key, null])) as Record; /** Switching the sample session clears every Lens key but the tab so ids never cross between live and sample data. */ const CLEARED_SESSION = nulls(Object.keys(SESSION_PARSERS).filter((key) => key !== "tab")); const CLEARED_RESULTS = nulls(Object.keys(RESULT_PARSERS)); +const CLEARED_SETUP = nulls(SETUP_KEYS); export interface LensRoute { readonly tab: LensTab | null; @@ -100,16 +104,42 @@ export function useListSearchRoute(): [string, (search: string) => void] { return [search, useCallback((next: string) => void setParams({ search: next }), [setParams])]; } +const SETUP_ROUTE_PARSERS = { ...DIALOG_PARSERS, ...SETUP_DRAFT_PARSERS, ...SETUP_STEP_PARSERS }; + +/** Opening or closing any dialog drops a setup draft left in the URL, so the next setup starts blank. */ export function useDialogRoute() { - const [{ dialog, target }, setParams] = useQueryStates(DIALOG_PARSERS, { history: "push" }); + const [{ dialog, target }, setParams] = useQueryStates(SETUP_ROUTE_PARSERS, { history: "push" }); const openDialog = useCallback( - (next: LensDialog, targetId: string | null = null) => void setParams({ dialog: next, target: targetId }), + (next: LensDialog, targetId: string | null = null) => + void setParams({ ...CLEARED_SETUP, dialog: next, target: targetId }), [setParams], ); - const closeDialog = useCallback(() => void setParams({ dialog: null, target: null }), [setParams]); + const closeDialog = useCallback(() => void setParams({ ...CLEARED_SETUP, dialog: null, target: null }), [setParams]); return { dialog, target, openDialog, closeDialog }; } +export interface InvestigateScope { + readonly q: string; + readonly lookbackHours: number; +} + +/** Starts a new investigation over the runs a Traces search shows. */ +export function useInvestigateRoute(): (scope: InvestigateScope) => void { + const [, setParams] = useQueryStates({ ...LENS_PARSERS, ...SETUP_ROUTE_PARSERS }, { history: "push" }); + return useCallback( + ({ q, lookbackHours }: InvestigateScope) => + void setParams({ + ...CLEARED_SETUP, + tab: "investigations", + dialog: "new", + target: null, + q, + lookback: lookbackHours, + }), + [setParams], + ); +} + const isResultTab = (tab: string): tab is ResultTab => (RESULT_TABS as readonly string[]).includes(tab); type FindingKind = (typeof FINDING_KINDS)[number]; diff --git a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.integration.test.tsx b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.integration.test.tsx index 19c94bea45a..1497af353ca 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.integration.test.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.integration.test.tsx @@ -194,21 +194,21 @@ it("walks the three steps in order, reopens a finished step from its summary, an renderWithProviders(); const steps = within(screen.getByRole("list", { name: "Investigation setup" })); expect(steps.getByRole("button", { name: /^Activity/ })).toHaveAttribute("aria-current", "step"); - expect(steps.getByRole("button", { name: /^Run/ })).toBeDisabled(); + expect(steps.getByRole("button", { name: /^Schedule/ })).toBeDisabled(); expect(screen.getByRole("region", { name: "Matching activity" })).toBeVisible(); expect(screen.queryByRole("textbox", { name: "What should the agent be doing?" })).not.toBeInTheDocument(); await user.type(screen.getByRole("combobox", { name: "Search runs" }), "agent:support_agent"); await user.keyboard("{Escape}"); await user.click(screen.getByRole("button", { name: "Continue" })); expect(steps.getByRole("button", { name: /^Activity.*agent:support_agent/ })).toBeEnabled(); - expect(screen.queryByRole("textbox", { name: "Investigation name" })).not.toBeInTheDocument(); + expect(screen.queryByRole("combobox", { name: "Search runs" })).not.toBeInTheDocument(); expect(screen.getByRole("textbox", { name: "What should the agent be doing?" })).toBeVisible(); await user.click(screen.getByRole("button", { name: "Continue" })); - expect(screen.getByRole("checkbox", { name: "Keep watching for new traces" })).toBeVisible(); + expect(screen.getByRole("switch", { name: "Keep watching for new traces" })).toBeVisible(); expect(screen.getByRole("region", { name: "Matching activity" })).toBeVisible(); await user.click(steps.getByRole("button", { name: /^Activity/ })); - expect(screen.getByRole("textbox", { name: "Investigation name" })).toBeVisible(); - expect(screen.queryByRole("checkbox", { name: "Keep watching for new traces" })).not.toBeInTheDocument(); + expect(screen.getByRole("combobox", { name: "Search runs" })).toBeVisible(); + expect(screen.queryByRole("switch", { name: "Keep watching for new traces" })).not.toBeInTheDocument(); }); it("searches providers and saves custom history while preserving existing schedule values", async () => { @@ -354,10 +354,9 @@ it("keeps a duplicated investigation's schedule off and saves the interval once await user.click(screen.getByRole("button", { name: "Continue" })); await user.click(screen.getByRole("button", { name: "Continue" })); await waitFor(() => expect(screen.getByRole("button", { name: "Run investigation" })).toBeEnabled()); - expect(screen.getByRole("checkbox", { name: "Keep watching for new traces" })).not.toBeChecked(); - await user.click(screen.getByText("Advanced options")); - fireEvent.change(screen.getByRole("spinbutton", { name: "Monthly limit (USD)" }), { target: { value: "8" } }); - await user.click(screen.getByRole("checkbox", { name: "Keep watching for new traces" })); + expect(screen.getByRole("switch", { name: "Keep watching for new traces" })).not.toBeChecked(); + fireEvent.change(screen.getByRole("spinbutton", { name: "Monthly limit" }), { target: { value: "8" } }); + await user.click(screen.getByRole("switch", { name: "Keep watching for new traces" })); fireEvent.change(screen.getByRole("spinbutton", { name: "Check every" }), { target: { value: "120" } }); await user.click(screen.getByRole("button", { name: "Run and monitor" })); expect(save).toHaveBeenCalledWith( @@ -365,14 +364,14 @@ it("keeps a duplicated investigation's schedule off and saves the interval once ); }); -it("watches new investigations every 15 minutes by default, outside advanced options", async () => { +it("watches new investigations every 15 minutes by default", async () => { const user = userEvent.setup(); const save = vi.fn().mockResolvedValue(undefined); mockGateway({ keyModels: ["analysis"] }); renderWithProviders(); await user.click(screen.getByRole("button", { name: "Continue" })); await user.click(screen.getByRole("button", { name: "Continue" })); - const watching = await screen.findByRole("checkbox", { name: "Keep watching for new traces" }); + const watching = await screen.findByRole("switch", { name: "Keep watching for new traces" }); expect(watching).toBeChecked(); expect(watching).toBeVisible(); await waitFor(() => expect(screen.getByRole("button", { name: "Run and monitor" })).toBeEnabled()); @@ -464,6 +463,51 @@ it.each(["empty", "error"])("blocks a new investigation when its preview is %s", expect(screen.getByRole("button", { name: "Run investigation" })).toBeDisabled(); }); +describe("URL draft", () => { + const lastUrl = (onUrlUpdate: ReturnType) => + new URLSearchParams((onUrlUpdate.mock.lastCall?.[0] as { queryString: string } | undefined)?.queryString ?? ""); + + it("opens a new investigation filled from the URL, previews that search, and writes edits back", async () => { + const user = userEvent.setup(); + const onUrlUpdate = vi.fn(); + renderWithProviders(, { + searchParams: "?q=agent:support_agent&lookback=72&sample=50&name=Refunds&step=criteria", + onUrlUpdate, + }); + expect(screen.getByRole("textbox", { name: "Investigation name" })).toHaveValue("Refunds"); + expect(screen.getByRole("button", { name: /^Criteria/ })).toHaveAttribute("aria-current", "step"); + expect(screen.getByRole("button", { name: /^Activity.*agent:support_agent.*3 days/ })).toBeEnabled(); + await waitFor(() => + expect(proxy.post).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: expect.objectContaining({ + lookback_hours: 72, + selection: expect.objectContaining({ q: "agent:support_agent", sample_percent: 50 }), + }), + }), + ), + ); + await user.type(screen.getByRole("textbox", { name: "What should the agent be doing?" }), "Refund with receipts"); + await waitFor(() => expect(lastUrl(onUrlUpdate).get("context")).toBe("Refund with receipts")); + expect(lastUrl(onUrlUpdate).get("q")).toBe("agent:support_agent"); + await user.click(screen.getByRole("button", { name: "Continue" })); + await waitFor(() => expect(lastUrl(onUrlUpdate).get("step")).toBe("run")); + }); + + it("edits an investigation from its saved settings, never from a draft left in the URL", () => { + const onUrlUpdate = vi.fn(); + renderWithProviders(, { + searchParams: "?name=Draft&lookback=72", + onUrlUpdate, + }); + expect(screen.getByRole("textbox", { name: "Investigation name" })).toHaveValue("Research quality"); + expect(screen.getByRole("spinbutton", { name: "Review the last" })).toHaveValue(1); + fireEvent.change(screen.getByRole("textbox", { name: "Investigation name" }), { target: { value: "Renamed" } }); + expect(onUrlUpdate).not.toHaveBeenCalled(); + }); +}); + describe("Watch for", () => { const tile = (name: string) => screen.getByRole("button", { name: new RegExp(`^${name}`) }); diff --git a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx index 10e6a6f1477..571b90cba0d 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/InvestigationSetup.tsx @@ -1,11 +1,11 @@ "use client"; import { FormProvider, useWatch, type UseFormReturn } from "react-hook-form"; -import { useEffect, useState } from "react"; -import { ChevronLeft } from "lucide-react"; +import { useEffect, useState, type ReactNode } from "react"; +import { ArrowLeft } from "lucide-react"; import { useZodForm } from "@/lib/forms/useZodForm"; import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; +import { FieldError } from "@/components/ui/field"; import { investigationSchema, investigationDefaults, @@ -13,8 +13,8 @@ import { investigationStepFields, type InvestigationInput, type InvestigationOutput, - type SetupStep as SetupStepId, } from "./investigationSchema"; +import { draftFromParams, useSetupDraftRoute, useSetupStepRoute } from "./setupRoute"; import { nextSetupStep, SetupStep, SetupSteps } from "./SetupSteps"; import { ScopeFields } from "./fields/ScopeFields"; import { SampleFields } from "./fields/SampleFields"; @@ -44,8 +44,11 @@ function saveLabelFor(mode: SetupMode, repeat: boolean): string { return repeat ? "Run and monitor" : "Run investigation"; } -function activitySummary(selection: InvestigationInput["selection"]): string { - return `${scopeLabel(selection)} · Last ${durationLabel(selection.lookback_hours ?? 24, "hours")}`; +function activitySummary(selection: InvestigationInput["selection"], manual: boolean): string { + const span = `Last ${durationLabel(selection.lookback_hours ?? 24, "hours")}`; + const picked = selection.execution_ids.length; + if (manual) return `${picked} picked ${picked === 1 ? "run" : "runs"} · ${span}`; + return `${scopeLabel(selection)} · ${span}`; } function criteriaSummary(values: Pick): string { @@ -66,10 +69,16 @@ interface SetupProps { /** Replaces the Investigations tab body: a three-step setup on the left, the activity it matches on the right. */ export function InvestigationSetup(props: SetupProps) { const { initial, mode } = props; + const draft = useSetupDraftRoute(); const form = useZodForm(investigationSchema, { - defaultValues: investigationDefaults(initial, mode), + 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 ( @@ -86,13 +95,13 @@ function SetupEditor({ form, }: SetupProps & { form: UseFormReturn }) { const analysis = useAnalysisModels(); - const [step, setStep] = useState("activity"); + const [step, setStep] = useSetupStepRoute(); const [error, setError] = useState(""); const [trace, setTrace] = useState(null); const { control, register, setValue, subscribe, trigger, formState } = form; - const [selectedModel, repeat, selection, context, watching, questions] = useWatch({ + const [selectedModel, repeat, selection, manualSelection, context, watching, questions] = useWatch({ control, - name: ["selectedModel", "repeat", "selection", "context", "watching", "questions"], + name: ["selectedModel", "repeat", "selection", "manualSelection", "context", "watching", "questions"], }); const activity = useMatchingActivity(); const traceRuns = activity.preview.page.executions.flatMap((run) => (run.summary ? [traceRefOf(run.summary)] : [])); @@ -127,52 +136,50 @@ function SetupEditor({ const saveLabel = saveLabelFor(mode, repeat); const offline = !ready && mode !== "edit"; return ( -
-
-
- -
-

{TITLES[mode]}

-

- Matching activity on the right updates as you change the setup. -

-
-
- + +
{offline && ( -

+

The worker or trace storage is unavailable. Your draft is safe; you can start when it reconnects.

)} -
+
- -
+ -
+
-
+ -
+
- + - {error && ( -

- {error} -

- )} -
+ {formState.errors.selection?.execution_ids?.message || error} + -
+
); } + +function StepFooter({ children }: { children: ReactNode }) { + return
{children}
; +} diff --git a/ui/litellm-dashboard/src/components/lens/setup/MatchingActivityPreview.tsx b/ui/litellm-dashboard/src/components/lens/setup/MatchingActivityPreview.tsx index 490db86cf95..6226951e43e 100644 --- a/ui/litellm-dashboard/src/components/lens/setup/MatchingActivityPreview.tsx +++ b/ui/litellm-dashboard/src/components/lens/setup/MatchingActivityPreview.tsx @@ -28,20 +28,20 @@ function PreviewFooter({ page, selection, className, ...props }: PreviewFooterPr return (
-

- {count} selected for analysis +

+ {count.toLocaleString()} selected for analysis {partial && ( <> {" "} - · Showing {page.executions.length} of {page.eligible} + · Showing {page.executions.length.toLocaleString()} of {page.eligible.toLocaleString()} )}

{selection && picked > 0 && ( - )} @@ -65,26 +65,27 @@ export function MatchingActivityPreview({
-
-
-

+

+
+

{status.title}

- +

{status.windowLabel} · Previewing is free

-

{status.windowLabel} · No analysis cost

-
+ + {status.ready && status.error && (

{status.error.message}{" "} @@ -94,12 +95,15 @@ export function MatchingActivityPreview({

)} {status.ready && page.eligible === 0 && ( -

- No matches. Try removing a filter from the search. Recent trace updates need two minutes to settle. -

+
+

No matches

+

+ Try removing a filter from the search. Recent trace updates need two minutes to settle. +

+
)} {(status.loading || (shown && !status.error && runs.length > 0)) && ( -
+
-