mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
refactor(ui): let nuqs own Lens URL defaults and session clearing
Draft parsers carry their defaults, so nuqs clearOnDefault keeps an untouched draft out of the URL instead of hand-rolled default checks. useLensRoute subscribes only to the keys it reads, and the sample session switch clears its keys with setParams(null). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
136fdc23be
commit
673d6cba43
5 changed files with 121 additions and 105 deletions
|
|
@ -19,7 +19,14 @@ 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, useInvestigateRoute, useLensRoute, type LensDialog, type LensTab } from "./route";
|
||||
import {
|
||||
useDemoRoute,
|
||||
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";
|
||||
|
|
@ -84,7 +91,8 @@ const PANEL =
|
|||
|
||||
function LensContent({ userRole, readOnly }: Omit<WorkspaceProps, "accessToken">) {
|
||||
const accessToken = useLensAccessToken();
|
||||
const { tab, lensId, demo, settingUp, setTab, setDemo, setSetup } = useLensRoute();
|
||||
const { tab, lensId, demo, settingUp, setTab, setSetup } = useLensRoute();
|
||||
const setDemo = useDemoRoute();
|
||||
const { dialog, openDialog } = useDialogRoute();
|
||||
const investigate = useInvestigateRoute();
|
||||
const { openTrace } = useOpenTraceRouting();
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { NuqsTestingAdapter, type OnUrlUpdateFunction } from "nuqs/adapters/test
|
|||
import type { PropsWithChildren } from "react";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { useDialogRoute, useInvestigateRoute } from "./route";
|
||||
import { useDemoRoute, useDialogRoute, useInvestigateRoute } from "./route";
|
||||
|
||||
const renderRoute = <T,>(hook: () => T, searchParams: string) => {
|
||||
const onUrlUpdate = vi.fn<OnUrlUpdateFunction>();
|
||||
|
|
@ -22,12 +22,8 @@ describe("setup routes", () => {
|
|||
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",
|
||||
});
|
||||
const expected = { tab: "investigations", dialog: "new", q: "agent:support status:error", lookback: "168" };
|
||||
expect(Object.fromEntries(lastUrl())).toEqual(expected);
|
||||
});
|
||||
|
||||
it("closing setup clears the draft but keeps the run search the Traces tab shares", async () => {
|
||||
|
|
@ -40,3 +36,21 @@ describe("setup routes", () => {
|
|||
expect(Object.fromEntries(lastUrl())).toEqual({ tab: "investigations", q: "agent:support" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("demo route", () => {
|
||||
const live = "?tab=investigations&lens=l1&run=b1&finding=f1&q=agent:support&dialog=new&name=Refunds";
|
||||
|
||||
it("entering the sample session keeps only the tab so live ids never leak into it", async () => {
|
||||
const { result, lastUrl } = renderRoute(useDemoRoute, live);
|
||||
await act(async () => result.current(true));
|
||||
await waitFor(() => expect(lastUrl().get("demo")).toBe("true"));
|
||||
expect(Object.fromEntries(lastUrl())).toEqual({ tab: "investigations", demo: "true" });
|
||||
});
|
||||
|
||||
it("leaving the sample session drops its ids and keeps the tab", async () => {
|
||||
const { result, lastUrl } = renderRoute(useDemoRoute, "?tab=traces&demo=true&lens=sample&q=agent:demo");
|
||||
await act(async () => result.current(false));
|
||||
await waitFor(() => expect(lastUrl().has("demo")).toBe(false));
|
||||
expect(Object.fromEntries(lastUrl())).toEqual({ tab: "traces" });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -53,8 +53,6 @@ const SESSION_PARSERS = {
|
|||
};
|
||||
const nulls = <K extends string>(keys: readonly K[]) =>
|
||||
Object.fromEntries(keys.map((key) => [key, null])) as Record<K, null>;
|
||||
/** 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);
|
||||
|
||||
|
|
@ -65,24 +63,36 @@ export interface LensRoute {
|
|||
readonly settingUp: boolean;
|
||||
setTab(tab: LensTab): void;
|
||||
setLensId(lensId: string | null): void;
|
||||
setDemo(demo: boolean): void;
|
||||
setSetup(settingUp: boolean): void;
|
||||
}
|
||||
|
||||
/** Lens navigation lives in the URL, sample session included, so any view is a shareable link. */
|
||||
export function useLensRoute(): LensRoute {
|
||||
const [{ tab, lens, demo, setup }, setParams] = useQueryStates(SESSION_PARSERS, { history: "push" });
|
||||
const [{ tab, lens, demo, setup }, setParams] = useQueryStates(
|
||||
{ ...LENS_PARSERS, ...ISSUE_PARSERS, ...RESULT_PARSERS },
|
||||
{ history: "push" },
|
||||
);
|
||||
const setTab = useCallback((next: LensTab) => void setParams({ tab: next }), [setParams]);
|
||||
const setLensId = useCallback(
|
||||
(next: string | null) => void setParams({ ...CLEARED_RESULTS, lens: next, issue: null }),
|
||||
[setParams],
|
||||
);
|
||||
const setDemo = useCallback(
|
||||
(next: boolean) => void setParams(next ? { ...CLEARED_SESSION, demo: true } : CLEARED_SESSION),
|
||||
const setSetup = useCallback((next: boolean) => void setParams({ setup: next ? "lens" : null }), [setParams]);
|
||||
return { tab, lensId: lens, demo, settingUp: setup === "lens", setTab, setLensId, setSetup };
|
||||
}
|
||||
|
||||
const { tab: _tab, ...SWITCHED_PARSERS } = SESSION_PARSERS;
|
||||
|
||||
/** Switching the sample session clears every Lens key but the tab so ids never cross between live and sample data. */
|
||||
export function useDemoRoute(): (demo: boolean) => void {
|
||||
const [, setParams] = useQueryStates(SWITCHED_PARSERS, { history: "push" });
|
||||
return useCallback(
|
||||
(next: boolean) => {
|
||||
void setParams(null);
|
||||
if (next) void setParams({ demo: true });
|
||||
},
|
||||
[setParams],
|
||||
);
|
||||
const setSetup = useCallback((next: boolean) => void setParams({ setup: next ? "lens" : null }), [setParams]);
|
||||
return { tab, lensId: lens, demo, settingUp: setup === "lens", setTab, setLensId, setDemo, setSetup };
|
||||
}
|
||||
|
||||
const ISSUE_ROUTE_PARSERS = { ...ISSUE_PARSERS, lens: LENS_PARSERS.lens, ...RESULT_PARSERS };
|
||||
|
|
@ -124,18 +134,12 @@ export interface InvestigateScope {
|
|||
}
|
||||
|
||||
/** Starts a new investigation over the runs a Traces search shows. */
|
||||
const NEW_INVESTIGATION = { ...CLEARED_SETUP, tab: "investigations", dialog: "new", target: null } as const;
|
||||
|
||||
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,
|
||||
}),
|
||||
({ q, lookbackHours }: InvestigateScope) => void setParams({ ...NEW_INVESTIGATION, q, lookback: lookbackHours }),
|
||||
[setParams],
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,62 +1,54 @@
|
|||
import { createSerializer } from "nuqs";
|
||||
import { createLoader, createSerializer } from "nuqs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { investigationDefaults } from "./investigationSchema";
|
||||
import { draftFromParams, paramsFromDraft, SETUP_DRAFT_PARSERS, type SetupDraftParams } from "./setupRoute";
|
||||
import { draftFromParams, paramsFromDraft, SETUP_DRAFT_PARSERS } from "./setupRoute";
|
||||
|
||||
const blank: SetupDraftParams = {
|
||||
q: "",
|
||||
name: null,
|
||||
lookback: null,
|
||||
sample: null,
|
||||
max: null,
|
||||
context: null,
|
||||
watch: null,
|
||||
checks: null,
|
||||
monitor: null,
|
||||
every: null,
|
||||
};
|
||||
const toUrl = createSerializer(SETUP_DRAFT_PARSERS);
|
||||
const fromUrl = createLoader(SETUP_DRAFT_PARSERS);
|
||||
const blank = investigationDefaults(undefined, "new");
|
||||
|
||||
describe("setup draft params", () => {
|
||||
it("opens a blank draft from an empty URL and keeps an untouched draft out of it", () => {
|
||||
const draft = draftFromParams(blank);
|
||||
expect(draft).toEqual(investigationDefaults(undefined, "new"));
|
||||
expect(paramsFromDraft(draft)).toEqual(blank);
|
||||
expect(draftFromParams(fromUrl(""))).toEqual(blank);
|
||||
expect(toUrl(paramsFromDraft(blank))).toBe("");
|
||||
});
|
||||
|
||||
it("round-trips every edited field through the URL, commas in checks included", () => {
|
||||
const edited: SetupDraftParams = {
|
||||
q: "agent:support status:error",
|
||||
const edited = {
|
||||
...blank,
|
||||
name: "Refund quality",
|
||||
lookback: 72,
|
||||
sample: 12.5,
|
||||
max: 40,
|
||||
selection: {
|
||||
...blank.selection,
|
||||
q: "agent:support status:error",
|
||||
lookback_hours: 72,
|
||||
sample_percent: 12.5,
|
||||
sample_size: 40,
|
||||
},
|
||||
context: "Refund only with a receipt",
|
||||
watch: ["watch_looping"],
|
||||
checks: ["Quotes a price, then changes it"],
|
||||
monitor: false,
|
||||
every: 60,
|
||||
watching: ["watch_looping"],
|
||||
questions: [{ id: "c1", instruction: "Quotes a price, then changes it", enabled: true }],
|
||||
repeat: !blank.repeat,
|
||||
interval: 60,
|
||||
};
|
||||
const url = createSerializer(SETUP_DRAFT_PARSERS)(edited);
|
||||
const parsed = Object.fromEntries(
|
||||
Object.entries(SETUP_DRAFT_PARSERS).map(([key, parser]) => [
|
||||
key,
|
||||
parser.parseServerSide(new URLSearchParams(url).get(key) ?? undefined),
|
||||
]),
|
||||
) as SetupDraftParams;
|
||||
expect(parsed).toEqual(edited);
|
||||
expect(paramsFromDraft(draftFromParams(parsed))).toEqual(edited);
|
||||
const restored = draftFromParams(fromUrl(toUrl(paramsFromDraft(edited))));
|
||||
expect({ ...restored, questions: restored.questions.map((check) => check.instruction) }).toEqual({
|
||||
...edited,
|
||||
questions: ["Quotes a price, then changes it"],
|
||||
});
|
||||
});
|
||||
|
||||
it("drops watch ids it does not know and keeps an explicitly empty watch list", () => {
|
||||
expect(draftFromParams({ ...blank, watch: ["watch_unsafe", "watch_gone"] }).watching).toEqual(["watch_unsafe"]);
|
||||
const none = draftFromParams({ ...blank, watch: [] });
|
||||
expect(draftFromParams(fromUrl("?watch=watch_unsafe,watch_gone")).watching).toEqual(["watch_unsafe"]);
|
||||
const none = draftFromParams(fromUrl(toUrl(paramsFromDraft({ ...blank, watching: [] }))));
|
||||
expect(none.watching).toEqual([]);
|
||||
expect(paramsFromDraft(none).watch).toEqual([]);
|
||||
});
|
||||
|
||||
it("leaves half-typed numbers out of the URL instead of writing NaN", () => {
|
||||
const draft = draftFromParams(blank);
|
||||
const typing = { ...draft, selection: { ...draft.selection, lookback_hours: NaN, sample_percent: NaN } };
|
||||
expect(paramsFromDraft(typing)).toEqual(blank);
|
||||
it("leaves half-typed numbers and blank checks out of the URL", () => {
|
||||
const typing = {
|
||||
...blank,
|
||||
selection: { ...blank.selection, lookback_hours: NaN, sample_percent: NaN },
|
||||
questions: [{ id: "c1", instruction: " ", enabled: true }],
|
||||
};
|
||||
expect(toUrl(paramsFromDraft(typing))).toBe("");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -9,24 +9,28 @@ import {
|
|||
parseAsStringLiteral,
|
||||
useQueryStates,
|
||||
type inferParserType,
|
||||
type Nullable,
|
||||
} from "nuqs";
|
||||
import { useCallback } from "react";
|
||||
import { RUN_FILTER_PARSERS } from "../traces/routing";
|
||||
import { watches } from "../model/watches";
|
||||
import { investigationDefaults, SETUP_STEPS, type InvestigationInput, type SetupStep } from "./investigationSchema";
|
||||
|
||||
const NEW_DRAFT = investigationDefaults(undefined, "new");
|
||||
const knownWatch = new Set(watches.map((watch) => watch.id));
|
||||
|
||||
/** A new investigation's draft lives in the URL, so a search on the Traces tab can open setup already filled in. */
|
||||
export const SETUP_DRAFT_PARSERS = {
|
||||
q: RUN_FILTER_PARSERS.q,
|
||||
name: parseAsString,
|
||||
lookback: parseAsInteger,
|
||||
sample: parseAsFloat,
|
||||
name: parseAsString.withDefault(NEW_DRAFT.name),
|
||||
lookback: parseAsInteger.withDefault(NEW_DRAFT.selection.lookback_hours),
|
||||
sample: parseAsFloat.withDefault(NEW_DRAFT.selection.sample_percent),
|
||||
max: parseAsInteger,
|
||||
context: parseAsString,
|
||||
watch: parseAsArrayOf(parseAsString),
|
||||
checks: parseAsArrayOf(parseAsString),
|
||||
monitor: parseAsBoolean,
|
||||
every: parseAsInteger,
|
||||
context: parseAsString.withDefault(NEW_DRAFT.context),
|
||||
watch: parseAsArrayOf(parseAsString).withDefault(NEW_DRAFT.watching),
|
||||
checks: parseAsArrayOf(parseAsString).withDefault([]),
|
||||
monitor: parseAsBoolean.withDefault(NEW_DRAFT.repeat),
|
||||
every: parseAsInteger.withDefault(NEW_DRAFT.interval),
|
||||
};
|
||||
|
||||
export const SETUP_STEP_PARSERS = { step: parseAsStringLiteral(SETUP_STEPS) };
|
||||
|
|
@ -37,52 +41,46 @@ export const SETUP_KEYS = [...Object.keys(SETUP_DRAFT_PARSERS), ...Object.keys(S
|
|||
);
|
||||
|
||||
export type SetupDraftParams = inferParserType<typeof SETUP_DRAFT_PARSERS>;
|
||||
type SetupDraftUpdate = Nullable<SetupDraftParams>;
|
||||
|
||||
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[]) =>
|
||||
a.length === b.length && a.every((value, index) => value === b[index]);
|
||||
const unlessDefault = <T>(value: T, fallback: T): T | null => (value === fallback ? null : value);
|
||||
|
||||
export function draftFromParams(params: SetupDraftParams): InvestigationInput {
|
||||
const selection = NEW_DRAFT.selection;
|
||||
return {
|
||||
...NEW_DRAFT,
|
||||
name: params.name ?? NEW_DRAFT.name,
|
||||
name: params.name,
|
||||
selection: {
|
||||
...selection,
|
||||
...NEW_DRAFT.selection,
|
||||
q: params.q,
|
||||
lookback_hours: params.lookback ?? selection.lookback_hours,
|
||||
sample_percent: params.sample ?? selection.sample_percent,
|
||||
sample_size: params.max ?? selection.sample_size,
|
||||
lookback_hours: params.lookback,
|
||||
sample_percent: params.sample,
|
||||
sample_size: params.max ?? NEW_DRAFT.selection.sample_size,
|
||||
},
|
||||
context: params.context ?? NEW_DRAFT.context,
|
||||
watching: params.watch ? params.watch.filter((id) => knownWatch.has(id)) : NEW_DRAFT.watching,
|
||||
questions: (params.checks ?? []).map((instruction) => ({ id: crypto.randomUUID(), instruction, enabled: true })),
|
||||
repeat: params.monitor ?? NEW_DRAFT.repeat,
|
||||
interval: params.every ?? NEW_DRAFT.interval,
|
||||
context: params.context,
|
||||
watching: params.watch.filter((id) => knownWatch.has(id)),
|
||||
questions: params.checks.map((instruction) => ({ id: crypto.randomUUID(), instruction, enabled: true })),
|
||||
repeat: params.monitor,
|
||||
interval: params.every,
|
||||
};
|
||||
}
|
||||
|
||||
/** Only what differs from a blank draft reaches the URL, so an untouched setup keeps a short link. */
|
||||
export function paramsFromDraft(draft: DraftFields): SetupDraftParams {
|
||||
const finite = (value: number | null) => (value != null && Number.isFinite(value) ? value : null);
|
||||
|
||||
/** nuqs drops values equal to their defaults from the URL, so an untouched setup keeps a short link. */
|
||||
export function paramsFromDraft(draft: DraftFields): SetupDraftUpdate {
|
||||
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);
|
||||
return {
|
||||
q: selection.q,
|
||||
name: draft.name || null,
|
||||
lookback: unlessDefault(finite(selection.lookback_hours), NEW_DRAFT.selection.lookback_hours),
|
||||
sample: unlessDefault(finite(selection.sample_percent), NEW_DRAFT.selection.sample_percent),
|
||||
name: draft.name,
|
||||
lookback: finite(selection.lookback_hours),
|
||||
sample: finite(selection.sample_percent),
|
||||
max: finite(selection.sample_size),
|
||||
context: draft.context || null,
|
||||
watch: sameList(draft.watching, NEW_DRAFT.watching) ? null : draft.watching,
|
||||
checks: checks.length ? checks : null,
|
||||
monitor: unlessDefault(draft.repeat, NEW_DRAFT.repeat),
|
||||
every: unlessDefault(finite(draft.interval), NEW_DRAFT.interval),
|
||||
context: draft.context,
|
||||
watch: draft.watching,
|
||||
checks: draft.questions.map((check) => check.instruction).filter((instruction) => instruction.trim()),
|
||||
monitor: draft.repeat,
|
||||
every: finite(draft.interval),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue