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:
Yujong Lee 2026-10-04 20:00:16 -07:00
parent 136fdc23be
commit 673d6cba43
5 changed files with 121 additions and 105 deletions

View file

@ -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();

View file

@ -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" });
});
});

View file

@ -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],
);
}

View file

@ -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("");
});
});

View file

@ -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),
};
}