fix(ui): restore inline Lens onboarding and responsive layout (#44604)

* fix(ui): improve gateway layouts on mobile

* fix(ui): limit mobile cleanup to navigation and header

* fix(ui): restore inline Lens onboarding and responsive layout

* fix(ui): smooth Lens tab and panel connections

* fix(ui): keep trace time controls within narrow panels
This commit is contained in:
moe-berri 2026-10-05 11:10:10 -07:00 • committed by GitHub
parent d80f8c28ca
commit a99bccacea
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 345 additions and 419 deletions

View file

@ -30,13 +30,12 @@ function ActivityDot({ activity }: { activity: InvestigationActivity }) {
);
}
/** Inverted corner joining the tab's side border to the card's top border; plain CSS borders so both snap to the same pixels. */
function NotchCorner({ side, demo }: { side: "left" | "right"; demo: boolean }) {
return (
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute bottom-0 size-3 overflow-hidden",
"pointer-events-none absolute bottom-0 hidden size-3 overflow-hidden @min-[24rem]/lens-frame:block",
side === "left" ? "-left-3" : "-right-3",
)}
>
@ -63,7 +62,10 @@ export function LensModeSwitch({
<div className={frameTab({ session: demo ? "demo" : "live" })}>
<NotchCorner side="left" demo={demo} />
<NotchCorner side="right" demo={demo} />
<TabsPrimitive.List aria-label="Lens" className="relative inline-flex h-9 items-center p-1">
<TabsPrimitive.List
aria-label="Lens"
className="relative flex h-9 items-center justify-center p-1 @min-[24rem]/lens-frame:justify-start"
>
<TabsPrimitive.Indicator className="absolute top-1 bottom-1 left-(--active-tab-left) w-(--active-tab-width) rounded-full bg-muted transition-[left,width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none" />
{tabs.map(([view, label]) => {
const Icon = MODE_ICONS[view as LensTab];
@ -78,7 +80,7 @@ export function LensModeSwitch({
}
className={cn(
"relative z-raised inline-flex h-full items-center gap-2 rounded-full text-sm font-medium text-muted-foreground outline-none transition-colors duration-200 hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-active:text-foreground",
view === "settings" ? "px-2.5" : "px-4",
view === "settings" ? "px-2.5" : "px-3 sm:px-4",
)}
>
<span className="relative inline-flex">

View file

@ -2,7 +2,7 @@ import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { dismissLensIntro, requestPath } from "@/../tests/lens-test-utils";
import { requestPath } from "@/../tests/lens-test-utils";
import LensPage from "@/app/(dashboard)/lens/page";
const { auth } = vi.hoisted(() => ({ auth: vi.fn() }));
@ -21,7 +21,6 @@ describe("Lens navigation", () => {
testQueryClient.clear();
window.localStorage.clear();
window.sessionStorage.clear();
dismissLensIntro();
auth.mockReturnValue({ accessToken: "test-token", userRole: "Admin", isViewOnly: false });
vi.stubGlobal(
"fetch",

View file

@ -2,9 +2,7 @@ import { act, fireEvent, screen, within, waitFor } from "@testing-library/react"
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { dismissLensIntro, readRequest, requestPath } from "@/../tests/lens-test-utils";
import { readStorage } from "@/lib/storage";
import { LENS_INTRO_DISMISSED, LENS_INTRO_SEEN } from "./storage";
import { readRequest, requestPath } from "@/../tests/lens-test-utils";
import { LensWorkspace } from "./LensWorkspace";
import { createLensDemoData } from "./data/demo/fixtures";
import type { LensList } from "./model/types";
@ -81,62 +79,44 @@ async function connectWorkerFromSettings(user: ReturnType<typeof userEvent.setup
}
describe("Lens introduction", () => {
it("opens on the first visit, rests for the session on close, and stays away after don't show again", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
const first = renderWorkspace({ onUrlUpdate });
const intro = within(await screen.findByRole("dialog"));
expect(await intro.findByRole("heading", { name: "The gateway that helps your agents improve" })).toBeVisible();
expect(intro.getByRole("heading", { name: "Before you start" })).toBeVisible();
await user.click(intro.getByRole("button", { name: "Close" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(readStorage(LENS_INTRO_SEEN)).toBe(true);
expect(readStorage(LENS_INTRO_DISMISSED)).toBe(false);
first.unmount();
const second = renderWorkspace();
expect(await screen.findByRole("tablist", { name: "Lens" })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
second.unmount();
window.sessionStorage.clear();
const third = renderWorkspace();
const reopened = within(await screen.findByRole("dialog"));
await user.click(reopened.getByRole("checkbox", { name: "Don’t show this again" }));
await user.click(reopened.getByRole("button", { name: "Close" }));
expect(readStorage(LENS_INTRO_DISMISSED)).toBe(true);
third.unmount();
window.sessionStorage.clear();
const fourth = renderWorkspace();
expect(await screen.findByRole("tablist", { name: "Lens" })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
fourth.unmount();
renderWorkspace({ searchParams: "?setup=lens" });
expect(await screen.findByRole("dialog")).toBeVisible();
});
it("lets settings bring back an introduction hidden with don't show again", async () => {
it("replaces both empty tabs with the introduction inside the page, including after a reload", async () => {
window.localStorage.setItem("lens.intro.dismissed", "true");
window.sessionStorage.setItem("lens.intro.seen", "true");
const user = userEvent.setup();
const first = renderWorkspace();
const intro = within(await screen.findByRole("dialog"));
await user.click(intro.getByRole("checkbox", { name: "Don’t show this again" }));
await user.click(intro.getByRole("button", { name: "Close" }));
const intro = await screen.findByRole("region", { name: "Get started with Lens" });
expect(
within(screen.getByRole("tabpanel", { name: "Traces" })).getByRole("region", {
name: "Get started with Lens",
}),
).toBe(intro);
expect(within(intro).getByRole("heading", { name: "Before you start" })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Enable tracing" })).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(
within(screen.getByRole("tabpanel", { name: "Investigations" })).getByRole("region", {
name: "Get started with Lens",
}),
).toBeVisible();
first.unmount();
const settings = renderWorkspace({ searchParams: "?tab=settings" });
const toggle = within(await screen.findByRole("region", { name: "Introduction" })).getByRole("switch", {
name: "Show the introduction on each new session",
});
expect(toggle).not.toBeChecked();
await user.click(toggle);
expect(toggle).toBeChecked();
settings.unmount();
window.sessionStorage.clear();
renderWorkspace();
expect(await screen.findByRole("dialog")).toBeVisible();
expect(await screen.findByRole("region", { name: "Get started with Lens" })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("opens explicit setup links in the page and clears setup when navigating to Settings", async () => {
serve({ enabled: true, traces: true });
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWorkspace({ onUrlUpdate, searchParams: "?setup=lens" });
expect(await screen.findByRole("region", { name: "Get started with Lens" })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(screen.getByRole("tab", { name: "Settings" }));
expect(await screen.findByRole("region", { name: "Settings" })).toBeVisible();
expect(screen.queryByRole("region", { name: "Get started with Lens" })).not.toBeInTheDocument();
expect(screen.queryByRole("switch", { name: "Show the introduction on each new session" })).not.toBeInTheDocument();
await waitFor(() => expect(setupParam(onUrlUpdate)).toBeNull());
});
it("never opens on its own inside the sample session", async () => {
@ -150,7 +130,6 @@ describe("Lens setup journey", () => {
it.each(["/lens", "/lens/activity/available"])(
"keeps recorded traces visible while %s is pending",
async (pendingPath) => {
dismissLensIntro();
serve({ enabled: true, traces: true });
const normal = network.getMockImplementation()!;
network.mockImplementation((input, init) =>
@ -164,7 +143,6 @@ describe("Lens setup journey", () => {
it.each(["/v1/traces", "/lens/activity/available"])(
"opens a saved investigation while %s is pending",
async (pendingPath) => {
dismissLensIntro();
serve();
list.mockResolvedValue({ lenses: data.lenses, workers: [worker()], tracing_enabled: false });
const normal = network.getMockImplementation()!;
@ -180,7 +158,7 @@ describe("Lens setup journey", () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWorkspace({ onUrlUpdate });
const intro = within(await screen.findByRole("dialog"));
const intro = within(await screen.findByRole("region", { name: "Get started with Lens" }));
await user.click(await intro.findByRole("button", { name: "Set up Lens" }));
await waitFor(() => expect(setupParam(onUrlUpdate)).toBe("lens"));
serve({ enabled: true });
@ -191,7 +169,8 @@ describe("Lens setup journey", () => {
serve({ enabled: true, traces: true });
await user.click(intro.getByRole("button", { name: "Check for traces" }));
expect(await intro.findByText(/Your first trace is ready/)).toBeVisible();
expect(screen.getByRole("dialog")).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
expect(screen.queryByRole("table", { name: "Agent runs" })).not.toBeInTheDocument();
await user.click(intro.getByRole("button", { name: "Continue to worker" }));
await waitFor(() => expect(setupParam(onUrlUpdate)).toBeNull());
await connectWorkerFromSettings(user);
@ -202,7 +181,7 @@ describe("Lens setup journey", () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWorkspace({ searchParams: "?tab=investigations&setup=lens", onUrlUpdate });
const intro = within(await screen.findByRole("dialog"));
const intro = within(await screen.findByRole("region", { name: "Get started with Lens" }));
expect(await intro.findByRole("button", { name: "Connect worker" })).toBeVisible();
expect(intro.getByRole("heading", { name: "Get Lens running" })).toBeVisible();
await user.click(intro.getByRole("button", { name: "View traces" }));
@ -216,7 +195,6 @@ describe("Lens setup journey", () => {
});
it("allows request-only investigations without forcing agent instrumentation", async () => {
dismissLensIntro();
serve({ requests: true });
const user = userEvent.setup();
const welcome = renderWorkspace({ searchParams: "?tab=investigations" });
@ -225,7 +203,7 @@ describe("Lens setup journey", () => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
welcome.unmount();
renderWorkspace({ searchParams: "?tab=investigations&setup=lens" });
const intro = within(await screen.findByRole("dialog"));
const intro = within(await screen.findByRole("region", { name: "Get started with Lens" }));
expect(await intro.findByRole("heading", { name: "Before you start" })).toBeVisible();
expect(intro.getByRole("button", { name: "Connect worker" })).toBeEnabled();
await user.click(intro.getByRole("button", { name: /Enable tracing on the gateway/ }));
@ -239,7 +217,7 @@ describe("Lens setup journey", () => {
serve({ enabled: true });
const user = userEvent.setup();
renderWorkspace({ searchParams: "?setup=lens" });
const intro = within(await screen.findByRole("dialog"));
const intro = within(await screen.findByRole("region", { name: "Get started with Lens" }));
await intro.findByRole("button", { name: "Check for traces" });
const normal = network.getMockImplementation()!;
network.mockImplementation(async (input, init) => {
@ -257,7 +235,6 @@ describe("Lens setup journey", () => {
});
it("keeps request-only users in investigations when an activity refresh fails", async () => {
dismissLensIntro();
serve({ requests: true, connected: true });
const user = userEvent.setup();
renderWorkspace({ searchParams: "?tab=investigations" });
@ -281,7 +258,7 @@ describe("Lens setup journey", () => {
it("keeps administrator-only setup unavailable to trace viewers", async () => {
serve({ enabled: true, traces: true });
renderWorkspace({ searchParams: "?setup=lens" }, "Internal User");
const intro = within(await screen.findByRole("dialog"));
const intro = within(await screen.findByRole("region", { name: "Get started with Lens" }));
expect(await intro.findByText(/A gateway administrator can connect a worker/)).toBeVisible();
expect(intro.getByRole("button", { name: "Connect worker" })).toBeDisabled();
expect(network.mock.calls.some(([input]) => requestPath(input) === "/lens")).toBe(false);
@ -306,7 +283,7 @@ describe("Lens setup journey", () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWorkspace({ searchParams: "?setup=lens", onUrlUpdate });
const intro = within(await screen.findByRole("dialog"));
const intro = within(await screen.findByRole("region", { name: "Get started with Lens" }));
await user.click(await intro.findByRole("button", { name: "New investigation" }));
const editor = within(await screen.findByRole("region", { name: "New investigation" }));
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();

View file

@ -2,7 +2,7 @@ import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { dismissLensIntro, readRequest, requestPath } from "@/../tests/lens-test-utils";
import { readRequest, requestPath } from "@/../tests/lens-test-utils";
import { LensWorkspace } from "./LensWorkspace";
import { lensKeys } from "./data/queries";
import { createLensDemoData } from "./data/demo/fixtures";
@ -17,7 +17,6 @@ beforeEach(() => {
testQueryClient.clear();
window.localStorage.clear();
window.sessionStorage.clear();
dismissLensIntro();
vi.stubGlobal("fetch", network);
network.mockReset();
network.mockImplementation(async (input) => {
@ -38,7 +37,7 @@ describe("Lens interactive demo", () => {
});
expect(await screen.findByRole("heading", { name: "The gateway that helps your agents improve" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Explore with sample data" }));
await user.click(screen.getByRole("switch", { name: "Demo data" }));
expect(await screen.findByText("Where is order #1042?")).toBeVisible();
expect(screen.getByRole("switch", { name: "Demo data" })).toBeChecked();
await expectUrl(onUrlUpdate, (url) => expect(url.get("demo")).toBe("true"));
@ -90,7 +89,7 @@ describe("Lens interactive demo", () => {
await expectUrl(onUrlUpdate, (url) => expect(url.get("view")).toBe("conversation"));
expect(network).not.toHaveBeenCalled();
await user.click(screen.getByRole("switch", { name: "Demo data" }));
expect(await screen.findByRole("heading", { name: "Enable tracing" })).toBeVisible();
expect(await screen.findByRole("region", { name: "Get started with Lens" })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await expectUrl(onUrlUpdate, (url) => expect([...url.keys()]).toEqual([]));
});
@ -103,7 +102,7 @@ describe("Lens interactive demo", () => {
searchParams: "?tab=traces&setup=lens&trace=live-trace&span=live-span&lens=live-lens",
onUrlUpdate,
});
await user.click(await screen.findByRole("button", { name: "Explore with sample data" }));
await user.click(await screen.findByRole("switch", { name: "Demo data" }));
expect(await screen.findByText("Where is order #1042?")).toBeVisible();
await expectUrl(onUrlUpdate, (url) =>
expect([...url.entries()]).toEqual([
@ -137,7 +136,7 @@ describe("Lens interactive demo", () => {
searchParams: "?tab=investigations",
onUrlUpdate,
});
await user.click(await screen.findByRole("button", { name: "Explore with sample data" }));
await user.click(await screen.findByRole("switch", { name: "Demo data" }));
network.mockClear();
await user.click(await screen.findByRole("row", { name: /Repeated lookups leave customers without an answer/ }));
const finding = screen.getByRole("complementary", { name: "Finding details" });
@ -168,7 +167,7 @@ describe("Lens interactive demo", () => {
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("has no demo entry for existing investigations, populated traces, or connecting another agent", async () => {
it("has no duplicate sample buttons for existing investigations or populated traces", async () => {
const user = userEvent.setup();
const data = createLensDemoData();
const saved = data.lenses[0];
@ -190,7 +189,7 @@ describe("Lens interactive demo", () => {
expect(screen.queryByRole("button", { name: "Set up tracing" })).not.toBeInTheDocument();
});
it("shows the header preview only for the active tab that still needs setup", async () => {
it("uses one demo switch across setup, existing investigations, and demo traces", async () => {
const user = userEvent.setup();
const saved = createLensDemoData().lenses[0];
network.mockImplementation(async (input) => {
@ -201,13 +200,15 @@ describe("Lens interactive demo", () => {
return Response.json({ data: [], traces: false, requests: false });
});
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />);
expect(await screen.findByRole("button", { name: "Preview sample" })).toBeVisible();
expect(await screen.findByRole("region", { name: "Get started with Lens" })).toBeVisible();
expect(screen.getAllByRole("switch", { name: "Demo data" })).toHaveLength(1);
expect(screen.queryByRole("button", { name: /Preview sample|Explore with sample data/ })).not.toBeInTheDocument();
const tabs = within(screen.getByRole("tablist", { name: "Lens" }));
await user.click(tabs.getByRole("tab", { name: "Investigations" }));
expect(await screen.findByRole("row", { name: new RegExp(saved.settings.name) })).toBeVisible();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
await user.click(tabs.getByRole("tab", { name: "Traces" }));
await user.click(await screen.findByRole("button", { name: "Preview sample" }));
await user.click(screen.getByRole("switch", { name: "Demo data" }));
expect(await screen.findByRole("table", { name: "Agent runs" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
});

View file

@ -2,14 +2,13 @@
import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Aperture, ArrowUpRight } from "lucide-react";
import { Aperture, ArrowUpRight, Loader2 } from "lucide-react";
import AgentTracesPage from "@/components/lens/traces/list/AgentTracesPage";
import { Button } from "@/components/ui/button";
import type { TraceSummary } from "@/components/lens/traces/types";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent } from "@/components/ui/tabs";
import { LensServicesProvider, useLensAccessToken, useLensApi, useLiveLensServices } from "./data/LensServices";
import { LensPreviewContext } from "@/components/lens/ui/LensPreviewButton";
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
import { InvestigationsView } from "./investigations/InvestigationsView";
import { LensSettings } from "./settings/LensSettings";
@ -19,10 +18,11 @@ import { LensModeSwitch } 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 { LensIntroDialog, useLensIntro } from "./onboarding/LensIntroDialog";
import { useDialogRoute, useIssueRoute, useLensRoute, type LensDialog, type LensTab } from "./route";
import { LensGettingStarted } from "./onboarding/LensGettingStarted";
import { useLensReadiness, type LensReadiness } from "./hooks/useLensReadiness";
import { OnboardingProvider, type Onboarding } from "./onboarding/OnboardingContext";
import { traceRefOf, useOpenTraceRouting } from "@/components/lens/traces/routing";
import { traceRefOf, useOpenTraceRouting, type TraceRef } from "@/components/lens/traces/routing";
type WorkspaceProps = { accessToken: string; userRole: string; readOnly: boolean };
@ -52,7 +52,12 @@ function SampleSession() {
function DemoToggle({ demo, onChange }: { demo: boolean; onChange: (demo: boolean) => void }) {
const id = useId();
return (
<div className={cn("flex items-center gap-2 text-xs", demo ? "font-medium text-info" : "text-muted-foreground")}>
<div
className={cn(
"flex items-center gap-2 whitespace-nowrap text-xs",
demo ? "font-medium text-info" : "text-muted-foreground",
)}
>
<label htmlFor={id}>Demo data</label>
<Switch id={id} size="sm" checked={demo} onCheckedChange={onChange} className="data-checked:bg-info" />
</div>
@ -80,48 +85,56 @@ function LensContent({ userRole, readOnly }: Omit<WorkspaceProps, "accessToken">
const accessToken = useLensAccessToken();
const { tab, lensId, demo, settingUp, setTab, setDemo, setSetup } = useLensRoute();
const { dialog, openDialog } = useDialogRoute();
const { openTrace } = useOpenTraceRouting();
const [previewTarget, setPreviewTarget] = useState<HTMLDivElement | null>(null);
const { issueKey } = useIssueRoute();
const { trace, openTrace } = useOpenTraceRouting();
const canViewInvestigations = isProxyAdminTierRole(userRole);
const isAdmin = isProxyAdminRole(userRole);
const canConfigure = canViewInvestigations && !readOnly;
const defaultTab = lensId ? "investigations" : "traces";
const activeTab = tab === "settings" && !canConfigure ? defaultTab : tab ?? defaultTab;
const intro = useLensIntro({ demo, settingUp });
const setupState = useLensReadiness(canViewInvestigations);
const setupLocation = {
tab: activeTab,
requested: settingUp,
canViewInvestigations,
trace,
lensId,
dialog,
issueKey,
};
const showSetup = !demo && needsSetup(setupState, setupLocation);
const { activity, list } = useLensOverview(
canViewInvestigations,
(canConfigure && activeTab === "settings") || intro.open,
(canConfigure && activeTab === "settings") || settingUp,
);
const workers = canConfigure && list ? list.workers : null;
const leaveIntro = (forever = false) => {
if (!intro.open) return;
intro.close(forever);
const leaveSetup = () => {
setSetup(false);
};
const startSetup = () => {
if (!settingUp) setSetup(true);
setSetup(true);
};
const exitIntro = (to: LensTab) => {
leaveIntro();
const exitSetup = (to: LensTab) => {
leaveSetup();
setTab(to);
};
const showSettings = () => {
leaveIntro();
leaveSetup();
setTab("settings");
};
const startFirstInvestigation = () => {
leaveIntro();
leaveSetup();
setTab("investigations");
openDialog("new");
};
const showSentTrace = (trace: TraceSummary) => {
leaveIntro();
leaveSetup();
setTab("traces");
openTrace(traceRefOf(trace));
};
const enterDemo = () => {
leaveIntro();
setDemo(true);
const toggleDemo = (next: boolean) => {
leaveSetup();
setDemo(next);
};
const onboarding: Onboarding = {
readOnly,
@ -132,21 +145,24 @@ function LensContent({ userRole, readOnly }: Omit<WorkspaceProps, "accessToken">
create: startFirstInvestigation,
openTrace: showSentTrace,
};
const preview = (view: LensTab) => ({
target: previewTarget,
open: !demo && !intro.open && activeTab === view ? enterDemo : undefined,
});
return (
<OnboardingProvider value={onboarding}>
<main className="flex h-full w-full min-w-0 flex-1 flex-col px-4 pt-3 pb-4">
<Tabs value={activeTab} onValueChange={(value) => setTab(value as LensTab)} className="min-h-0 flex-1 gap-0">
<div className="grid grid-cols-[1fr_auto_1fr] items-end gap-3">
<div className="flex min-w-0 flex-col gap-1 pb-3">
<main className="flex h-full w-full min-w-0 flex-1 flex-col px-3 pt-3 pb-3 sm:px-4 sm:pb-4">
<Tabs
value={activeTab}
onValueChange={(value) => {
if (value === "settings") leaveSetup();
setTab(value as LensTab);
}}
className="@container/lens-frame min-h-0 flex-1 gap-0"
>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-start gap-x-4 gap-y-1">
<div className="contents">
<h1 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
<Aperture aria-hidden="true" className="size-5" strokeWidth={2} />
Lens
</h1>
<p className="truncate text-xs text-muted-foreground">
<p className="col-span-2 row-start-2 text-xs leading-5 text-muted-foreground">
Trace your agents and investigate what goes wrong.{" "}
<a
href="https://docs.litellm.ai/docs/proxy/lens"
@ -159,41 +175,53 @@ function LensContent({ userRole, readOnly }: Omit<WorkspaceProps, "accessToken">
</a>
</p>
</div>
<LensModeSwitch
activity={activity}
demo={demo}
workers={workers}
setup={activeTab === "investigations" && dialog ? SETUP_LABELS[dialog] : undefined}
/>
<div className="flex min-w-0 flex-wrap items-center justify-end gap-3 pb-3">
<div ref={setPreviewTarget} />
<DemoToggle demo={demo} onChange={(next) => (next ? enterDemo() : setDemo(false))} />
<div className="col-span-2 row-start-3 mt-2 @min-[24rem]/lens-frame:ml-8 @min-[24rem]/lens-frame:justify-self-start">
<LensModeSwitch
activity={activity}
demo={demo}
workers={workers}
setup={activeTab === "investigations" && dialog ? SETUP_LABELS[dialog] : undefined}
/>
</div>
<div className="col-start-2 row-start-1 flex items-center justify-end pt-1">
<DemoToggle demo={demo} onChange={toggleDemo} />
</div>
</div>
<div className={frameCard({ session: demo ? "demo" : "live" })}>
<TabsContent value="traces" keepMounted className={PANEL}>
<LensPreviewContext.Provider value={preview("traces")}>
<AgentTracesPage
accessToken={accessToken}
isActive={activeTab === "traces"}
readOnly={readOnly}
canMintTracingKey={isAdmin}
/>
</LensPreviewContext.Provider>
</TabsContent>
<TabsContent value="investigations" className={PANEL}>
<LensPreviewContext.Provider value={preview("investigations")}>
{canViewInvestigations ? (
<InvestigationsView readOnly={readOnly || !isAdmin} />
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
{showSetup ? (
<TabsContent value={activeTab} keepMounted className={cn(PANEL, "p-3 sm:p-5")}>
{setupState.loading ? (
<p role="status" className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
Checking Lens setup…
</p>
) : (
<LensGettingStarted state={setupState} onStart={startSetup} onExit={exitSetup} />
)}
</LensPreviewContext.Provider>
</TabsContent>
</TabsContent>
) : (
<>
<TabsContent value="traces" keepMounted className={PANEL}>
<AgentTracesPage
accessToken={accessToken}
isActive={activeTab === "traces"}
readOnly={readOnly}
canMintTracingKey={isAdmin}
/>
</TabsContent>
<TabsContent value="investigations" className={PANEL}>
{canViewInvestigations ? (
<InvestigationsView readOnly={readOnly || !isAdmin} />
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</TabsContent>
</>
)}
{workers && list && (
<TabsContent value="settings" keepMounted className={cn(PANEL, "p-6")}>
<TabsContent value="settings" keepMounted className={cn(PANEL, "p-3 sm:p-5")}>
<LensSettings
list={list}
workerReadyAction={
@ -209,14 +237,27 @@ function LensContent({ userRole, readOnly }: Omit<WorkspaceProps, "accessToken">
)}
</div>
</Tabs>
<LensIntroDialog
open={intro.open}
onClose={leaveIntro}
onStart={startSetup}
onExit={exitIntro}
onDemo={enterDemo}
/>
</main>
</OnboardingProvider>
);
}
function needsSetup(
state: LensReadiness,
location: {
tab: LensTab;
requested: boolean;
canViewInvestigations: boolean;
trace: TraceRef | null;
lensId: string | null;
dialog: LensDialog | null;
issueKey: string | null;
},
) {
if (location.tab === "settings") return false;
if (location.requested) return true;
const selected = location.tab === "traces" ? location.trace : location.lensId || location.dialog || location.issueKey;
if (!state.missingTraces || selected) return false;
if (location.tab === "traces") return true;
return location.canViewInvestigations && !state.hasInvestigations && !state.hasRecordedActivity;
}

View file

@ -4,10 +4,12 @@ import { useQuery } from "@tanstack/react-query";
import { isTracingNotEnabled, useTraceAvailability } from "@/components/lens/traces/list/useAgentTraces";
import { useLensAccessToken, useLensApi } from "../data/LensServices";
import { lensQueries } from "../data/queries";
import { readiness, type Readiness, type ReadinessInput } from "../model/readiness";
import { readiness, recordedActivity, type Readiness, type ReadinessInput } from "../model/readiness";
import { useWorkerConnected } from "./useWorkerConnected";
export interface LensReadiness extends Readiness {
readonly missingTraces: boolean;
readonly hasRecordedActivity: boolean;
readonly loading: boolean;
readonly checking: boolean;
readonly error: string | undefined;
@ -50,6 +52,7 @@ export function useLensReadiness(canInvestigate: boolean): LensReadiness {
};
return {
...state,
...recordedActivity(input),
loading: loadingTraces || loadingInvestigations,
checking: traces.isFetching || list.isFetching || activity.isFetching,
error: error?.message,

View file

@ -262,6 +262,7 @@ export function InvestigationList({
const tableOptions: TableOptions<InvestigationRow> = {
data: shown.map((lens): InvestigationRow => ({ kind: "investigation", lens })),
columns: COLUMNS,
defaultColumn: { size: undefined },
getRowId: investigationRowKey,
getSubRows: findingRows,
initialState: { expanded: true },

View file

@ -6,16 +6,11 @@ import { renderWithLens, stubGateway } from "@/../tests/lens-test-utils";
import { ApiError } from "@/lib/http/client";
import { lensKeys } from "../data/queries";
import { InvestigationsView } from "./InvestigationsView";
import { LensPreviewContext } from "@/components/lens/ui/LensPreviewButton";
import { briefMarkdown } from "../model/findings";
import { findingKey } from "../model/inbox";
import { runTime } from "../model/format";
import { type Lens, type Finding } from "../model/types";
const withPreview = (ui: React.ReactElement, open: () => void) => (
<LensPreviewContext.Provider value={{ target: document.body, open }}>{ui}</LensPreviewContext.Provider>
);
function renderWithProviders(ui: React.ReactElement, options?: Parameters<typeof renderWithLens>[1]) {
return renderWithLens(ui, { searchParams: window.location.search, ...options });
}
@ -311,21 +306,6 @@ it("runs with saved settings from Run now without opening setup, then accepts an
expect(sentBody(proxy.post, "/lens/lens/runs")).toEqual([{ agent_name: "billing", lookback_hours: 24 }]);
});
it("offers the interactive demo without starting an investigation", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
proxy.get.mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [], workers: [], tracing_enabled: false };
return { traces: false, requests: false };
});
const onPreview = vi.fn();
const user = userEvent.setup();
renderWithProviders(withPreview(<InvestigationsView />, onPreview));
await user.click(await screen.findByRole("button", { name: "Preview sample" }));
expect(onPreview).toHaveBeenCalledOnce();
expect(proxy.post).not.toHaveBeenCalled();
});
it("guides a first-time administrator into worker connection and lens setup", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
@ -337,7 +317,7 @@ it("guides a first-time administrator into worker connection and lens setup", as
const user = userEvent.setup();
const connect = vi.fn();
const create = vi.fn();
renderWithProviders(withPreview(<InvestigationsView />, vi.fn()), {
renderWithProviders(<InvestigationsView />, {
onboarding: { connect, create },
});
const guide = within(await screen.findByRole("region", { name: "Get Lens running" }));
@ -345,11 +325,11 @@ it("guides a first-time administrator into worker connection and lens setup", as
"/lens/activity/available",
expect.objectContaining({ authorization: "Bearer test" }),
);
expect(guide.getByRole("button", { name: /Send your first trace/ })).toContainElement(
guide.getByLabelText("Step 2 complete"),
);
expect(guide.queryByRole("button", { name: /Send your first trace/ })).not.toBeInTheDocument();
expect(guide.queryByRole("button", { name: /Enable tracing on the gateway/ })).not.toBeInTheDocument();
expect(guide.getByRole("button", { name: /Connect a worker/ })).toHaveAttribute("aria-expanded", "true");
expect(guide.queryByRole("button", { name: "View traces" })).not.toBeInTheDocument();
expect(await screen.findByRole("button", { name: "Preview sample" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
await user.click(guide.getByRole("button", { name: "Connect worker" }));
expect(connect).toHaveBeenCalledOnce();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
@ -486,8 +466,8 @@ it("enables first-lens setup when a trace arrives without leaving Investigations
traceCheck.mockResolvedValue({ traces: true, requests: false });
await act(async () => vi.advanceTimersByTimeAsync(5000));
expect(screen.getByText(/Your first trace is ready/)).toBeVisible();
expect(screen.getByRole("button", { name: "Continue to worker" })).toBeEnabled();
expect(screen.queryByRole("button", { name: /Send your first trace/ })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Connect worker" })).toBeEnabled();
const completedChecks = traceCheck.mock.calls.length;
await act(async () => vi.advanceTimersByTimeAsync(5000 * 2));
@ -560,10 +540,9 @@ it("allows request-only accounts to connect a worker without requiring agent tra
const user = userEvent.setup();
renderWithProviders(<InvestigationsView />);
expect(await screen.findByRole("button", { name: "Connect worker" })).toBeEnabled();
await user.click(screen.getByRole("button", { name: /Send your first trace/ }));
const panel = within(screen.getByRole("region", { name: /Send your first trace/ }));
expect(panel.getByText(/Request logs are already available/)).toBeVisible();
expect(panel.getByRole("button", { name: "Continue with request logs" })).toBeEnabled();
expect(screen.queryByRole("button", { name: /Send your first trace/ })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: /Run your first investigation/ }));
expect(screen.getByRole("button", { name: "New investigation" })).toBeDisabled();
});
it("reopens the inline editor from a shared link and drops it from the URL on cancel", async () => {

View file

@ -5,7 +5,6 @@ import { useQuery } from "@tanstack/react-query";
import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { LensPreviewButton } from "@/components/lens/ui/LensPreviewButton";
import { useInvalidateLenses } from "../data/mutations";
import { lensQueries } from "../data/queries";
@ -114,10 +113,11 @@ export function InvestigationsView({ readOnly = false }: InvestigationsViewProps
case "welcome":
if (status.loading) return <InvestigationsLoading />;
return (
<>
{!status.ready && <LensPreviewButton />}
<OnboardingSetup state={status} className="mx-auto w-full max-w-3xl py-6" />
</>
<OnboardingSetup
state={status}
includeTracing={!status.hasRecordedActivity}
className="mx-auto w-full max-w-3xl p-4 sm:p-6"
/>
);
case "list":
return (

View file

@ -1,5 +1,5 @@
import { describe, expect, it } from "vitest";
import { initialSetupStep, readiness, type ReadinessInput } from "./readiness";
import { initialSetupStep, readiness, recordedActivity, type ReadinessInput } from "./readiness";
const base: ReadinessInput = {
traces: { recorded: undefined, failed: false, disabled: false, checked: false },
@ -51,3 +51,23 @@ describe("initialSetupStep", () => {
expect(initialSetupStep(readiness({ ...recorded, connected: true }))).toBe(3);
});
});
describe("recordedActivity", () => {
it("requires a confirmed empty response or disabled tracing before showing trace setup", () => {
expect(recordedActivity(base).missingTraces).toBe(false);
expect(recordedActivity({ ...base, traces: { ...base.traces, failed: true } }).missingTraces).toBe(false);
expect(recordedActivity({ ...base, traces: { ...base.traces, recorded: false } }).missingTraces).toBe(true);
expect(recordedActivity({ ...base, traces: { ...base.traces, disabled: true } }).missingTraces).toBe(true);
});
it("preserves known activity through failed refreshes without enabling investigation actions", () => {
const failed = { ...base, activityError: new Error("offline"), traces: { ...base.traces, failed: true } };
const knownTraces = { ...failed, traces: { ...failed.traces, recorded: true } };
expect(recordedActivity(knownTraces)).toEqual({ missingTraces: false, hasRecordedActivity: true });
expect(readiness(knownTraces).ready).toBe(false);
const knownRequests = { ...failed, activity: { traces: false, requests: true } };
expect(recordedActivity(knownRequests).hasRecordedActivity).toBe(true);
expect(readiness(knownRequests).ready).toBe(false);
expect(recordedActivity({ ...failed, activity: { traces: true, requests: false } }).missingTraces).toBe(false);
});
});

View file

@ -45,3 +45,11 @@ export function initialSetupStep(state: Readiness): number {
if (state.activityReady) return state.connected ? 3 : 2;
return state.tracingEnabled ? 1 : 0;
}
export function recordedActivity({ traces, activity }: Pick<ReadinessInput, "traces" | "activity">) {
const hasTraces = traces.recorded === true || activity?.traces === true;
return {
missingTraces: !hasTraces && (traces.disabled || traces.recorded === false),
hasRecordedActivity: hasTraces || activity?.requests === true,
};
}

View file

@ -18,10 +18,9 @@ export interface LensGettingStartedProps {
readonly state: LensReadiness;
readonly onStart: () => void;
readonly onExit: (to: "traces" | "investigations") => void;
readonly onDemo?: () => void;
}
export function LensGettingStarted({ state, onStart, onExit, onDemo }: LensGettingStartedProps) {
export function LensGettingStarted({ state, onStart, onExit }: LensGettingStartedProps) {
const setupRef = useRef<HTMLElement>(null);
const exitTo = state.tracesReady ? "traces" : "investigations";
const start = () => {
@ -30,8 +29,8 @@ export function LensGettingStarted({ state, onStart, onExit, onDemo }: LensGetti
setupRef.current?.focus({ preventScroll: true });
};
return (
<div className="@container w-full space-y-6">
<LensIntroduction onStart={start} onDemo={onDemo} />
<section aria-label="Get started with Lens" className="@container mx-auto w-full max-w-7xl space-y-6">
<LensIntroduction onStart={start} />
<div className="grid items-start gap-6 @3xl:grid-cols-[minmax(0,1fr)_280px] @3xl:gap-8">
<OnboardingSetup
ref={setupRef}
@ -48,7 +47,7 @@ export function LensGettingStarted({ state, onStart, onExit, onDemo }: LensGetti
/>
<Prerequisites />
</div>
</div>
</section>
);
}

View file

@ -1,80 +0,0 @@
"use client";
import { useId, useState } from "react";
import { Loader2, XIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import { Dialog, DialogContent, DialogDescription, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { useStoredValue } from "@/lib/storage";
import { useLensReadiness } from "../hooks/useLensReadiness";
import { LENS_INTRO_DISMISSED, LENS_INTRO_SEEN } from "../storage";
import { LensGettingStarted, type LensGettingStartedProps } from "./LensGettingStarted";
import { useOnboarding } from "./OnboardingContext";
export interface LensIntro {
readonly open: boolean;
close(forever: boolean): void;
}
/** Opens on the first Lens visit of a session, or whenever the URL asks for setup. "Don't show this again" outlives the tab; a plain close only rests for the session. */
export function useLensIntro({ demo, settingUp }: { demo: boolean; settingUp: boolean }): LensIntro {
const [dismissed, setDismissed] = useStoredValue(LENS_INTRO_DISMISSED);
const [seen, setSeen] = useStoredValue(LENS_INTRO_SEEN);
const close = (forever: boolean) => {
setSeen(true);
if (forever) setDismissed(true);
};
const firstVisit = !demo && !dismissed && !seen;
return { open: settingUp || firstVisit, close };
}
export type LensIntroDialogProps = Omit<LensGettingStartedProps, "state"> & {
open: boolean;
onClose: (forever: boolean) => void;
};
export function LensIntroDialog({ open, onClose, ...gettingStarted }: LensIntroDialogProps) {
const [forever, setForever] = useState(false);
const checkboxId = useId();
const close = () => onClose(forever);
return (
<Dialog open={open} onOpenChange={(next) => !next && close()}>
<DialogContent
showCloseButton={false}
className="max-h-[calc(100vh-4rem)] gap-0 overflow-y-auto p-0 sm:max-w-4xl xl:max-w-5xl 2xl:max-w-6xl"
>
<div className="sticky top-0 z-raised flex items-center justify-end gap-3 bg-popover/90 px-4 py-2 backdrop-blur-sm">
<Label htmlFor={checkboxId} className="gap-2 text-xs font-normal text-muted-foreground">
<Checkbox id={checkboxId} checked={forever} onCheckedChange={(checked) => setForever(checked === true)} />
Don’t show this again
</Label>
<Button variant="ghost" size="icon-sm" aria-label="Close" onClick={close}>
<XIcon />
</Button>
</div>
<DialogTitle className="sr-only">Get started with Lens</DialogTitle>
<DialogDescription className="sr-only">
What Lens does, and the steps to connect tracing, a worker and your first investigation.
</DialogDescription>
<div className="px-5 pb-5">
<IntroContent {...gettingStarted} />
</div>
</DialogContent>
</Dialog>
);
}
function IntroContent(props: Omit<LensGettingStartedProps, "state">) {
const { canViewInvestigations } = useOnboarding();
const state = useLensReadiness(canViewInvestigations);
if (state.loading)
return (
<p role="status" className="flex items-center gap-2 py-8 text-sm text-muted-foreground">
<Loader2 aria-hidden="true" className="size-4 animate-spin" />
Checking Lens setup…
</p>
);
return <LensGettingStarted state={state} {...props} />;
}

View file

@ -1,5 +1,5 @@
import { useState } from "react";
import { ArrowRight, ArrowUpRight, ChevronDown, ChevronRight } from "lucide-react";
import { ArrowUpRight, ChevronDown, ChevronRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import styles from "./LensIntroduction.module.css";
@ -150,14 +150,11 @@ function FindingExamples({ highlighted, onHighlight }: EvidenceProps) {
);
}
export function LensIntroduction({ onStart, onDemo }: { onStart: () => void; onDemo?: () => void }) {
export function LensIntroduction({ onStart }: { onStart: () => void }) {
const [highlighted, setHighlighted] = useState(false);
const toggleEvidence = () => setHighlighted((current) => !current);
return (
<section
aria-labelledby="lens-introduction"
className="rounded-2xl border bg-card p-5 sm:px-6 sm:py-5 xl:px-7 xl:py-6"
>
<section aria-labelledby="lens-introduction" className="rounded-2xl border bg-card p-4 sm:px-6 sm:py-5 xl:px-7">
<h2 id="lens-introduction" className="text-2xl leading-tight font-semibold tracking-tight sm:text-3xl">
The gateway that helps your agents improve
</h2>
@ -165,12 +162,7 @@ export function LensIntroduction({ onStart, onDemo }: { onStart: () => void; onD
Turn recorded agent runs into findings linked to the exact steps, so you know what happened and what to change.
</p>
<div className="mt-4 flex flex-wrap items-center gap-3">
{onDemo && (
<Button className="rounded-lg px-4" onClick={onDemo}>
Explore with sample data <ArrowRight aria-hidden="true" className="size-4" />
</Button>
)}
<Button variant={onDemo ? "outline" : "default"} className="rounded-lg px-4" onClick={onStart}>
<Button className="rounded-lg px-4" onClick={onStart}>
Set up Lens
</Button>
<a
@ -184,7 +176,7 @@ export function LensIntroduction({ onStart, onDemo }: { onStart: () => void; onD
</div>
<GatewayFlow />
<div
className={cn(styles.examples, "mt-4 grid items-stretch gap-3 md:grid-cols-2")}
className={cn(styles.examples, "mt-4 grid items-stretch gap-3 @2xl:grid-cols-2")}
data-evidence-active={highlighted}
>
<TraceExample highlighted={highlighted} onHighlight={toggleEvidence} />

View file

@ -10,9 +10,10 @@ import { OnboardingSteps } from "./OnboardingSteps";
export type OnboardingSetupProps = Omit<ComponentProps<"section">, "children"> & {
state: LensReadiness;
action?: ReactNode;
includeTracing?: boolean;
};
export function OnboardingSetup({ state, action, className, ...props }: OnboardingSetupProps) {
export function OnboardingSetup({ state, action, includeTracing = true, className, ...props }: OnboardingSetupProps) {
const { readOnly, canInvestigate } = useOnboarding();
const titleId = useId();
return (
@ -33,7 +34,7 @@ export function OnboardingSetup({ state, action, className, ...props }: Onboardi
</div>
{action}
</div>
<OnboardingSteps state={state} />
<OnboardingSteps state={state} includeTracing={includeTracing} />
{state.error && (
<div role="alert" className="mt-4 flex flex-wrap items-center gap-3 text-sm text-destructive">
<p>Could not check setup. {state.error}</p>

View file

@ -119,7 +119,7 @@ function WorkerStep({ state }: StepProps) {
: "The worker reviews recorded activity using a model on your gateway. You choose its analysis model and spending limit."}
</p>
{!state.activityReady && (
<p className="text-sm text-muted-foreground">Record activity in step 2 before connecting a worker.</p>
<p className="text-sm text-muted-foreground">Recorded activity is required before connecting a worker.</p>
)}
<Button onClick={state.connected ? create : connect} disabled={!state.activityReady || locked}>
{state.connected ? "Continue to investigation" : "Connect worker"}
@ -189,10 +189,19 @@ function stepState(complete: boolean, open: boolean): StepState {
return open ? "current" : "upcoming";
}
export function OnboardingSteps({ state, className }: { state: LensReadiness; className?: string }) {
export function OnboardingSteps({
state,
className,
includeTracing,
}: {
state: LensReadiness;
className?: string;
includeTracing: boolean;
}) {
const id = useId();
const listRef = useRef<HTMLOListElement>(null);
const [step, setStep] = useState(() => initialSetupStep(state));
const offset = includeTracing ? 0 : 2;
const [step, setStep] = useState(() => Math.max(offset, initialSetupStep(state)));
const goTo = (index: number) => {
setStep(index);
listRef.current?.querySelector<HTMLButtonElement>(`[aria-controls="${id}-${index}"]`)?.focus();
@ -203,17 +212,18 @@ export function OnboardingSteps({ state, className }: { state: LensReadiness; cl
data-slot="onboarding-steps"
className={cn("divide-y overflow-hidden rounded-2xl border bg-card", className)}
>
{STEPS.map(({ title, description, complete, Content }, index) => {
const open = step === index;
{STEPS.slice(offset).map(({ title, description, complete, Content }, index) => {
const stepIndex = index + offset;
const open = Math.max(offset, step) === stepIndex;
return (
<li key={title}>
<h3>
<button
type="button"
id={`${id}-trigger-${index}`}
id={`${id}-trigger-${stepIndex}`}
aria-expanded={open}
aria-controls={`${id}-${index}`}
onClick={() => setStep(index)}
aria-controls={`${id}-${stepIndex}`}
onClick={() => setStep(stepIndex)}
className="group flex w-full items-start gap-4 p-5 text-left outline-none hover:bg-muted/30 focus-visible:bg-muted/50 sm:p-6"
>
<StepIndicator index={index} state={stepState(complete(state), open)} />
@ -235,9 +245,9 @@ export function OnboardingSteps({ state, className }: { state: LensReadiness; cl
</button>
</h3>
<div
id={`${id}-${index}`}
id={`${id}-${stepIndex}`}
role="region"
aria-labelledby={`${id}-trigger-${index}`}
aria-labelledby={`${id}-trigger-${stepIndex}`}
hidden={!open}
className="px-5 pb-6 sm:pr-6 sm:pb-7 sm:pl-17"
>

View file

@ -3,7 +3,6 @@ import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { chooseSelectOption, renderWithProviders } from "@/../tests/test-utils";
import { copyToClipboard } from "@/utils/dataUtils";
import { LensPreviewContext } from "../../ui/LensPreviewButton";
import { agentTraceCall, apiClient, sendOtlpTraceCall } from "../../../networking";
import {
codingAgentCommand,
@ -33,22 +32,19 @@ const renderCard = (
onCheck?: () => void;
readOnly?: boolean;
canMintTracingKey?: boolean;
onPreview?: () => void;
} = {},
) => {
const onOpenTrace = vi.fn();
renderWithProviders(
<LensPreviewContext.Provider value={{ target: document.body, open: props.onPreview }}>
<TracingSetupCard
detail={props.detail ?? null}
connected={props.connected}
onCheck={props.onCheck}
readOnly={props.readOnly}
canMintTracingKey={props.canMintTracingKey ?? true}
accessToken="sk-admin"
onOpenTrace={onOpenTrace}
/>
</LensPreviewContext.Provider>,
<TracingSetupCard
detail={props.detail ?? null}
connected={props.connected}
onCheck={props.onCheck}
readOnly={props.readOnly}
canMintTracingKey={props.canMintTracingKey ?? true}
accessToken="sk-admin"
onOpenTrace={onOpenTrace}
/>,
);
return { onOpenTrace, card: screen.getByTestId("tracing-setup-card") };
};
@ -56,15 +52,13 @@ const renderCard = (
beforeEach(() => vi.clearAllMocks());
describe("TracingSetupCard", () => {
it("offers the interactive demo while waiting for the first trace", async () => {
it("guides agent connection while waiting for the first trace", async () => {
const user = userEvent.setup();
const onPreview = vi.fn();
const { card } = renderCard({ onPreview });
const { card } = renderCard();
expect(screen.getByRole("heading", { name: "Connect your agent" })).toBeVisible();
expect(screen.getByText("Tracing enabled")).toBeVisible();
expect(screen.getByText("Waiting for your first trace")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Preview sample" }));
expect(onPreview).toHaveBeenCalledOnce();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
expect(sendOtlpTraceCall).not.toHaveBeenCalled();
expect(card).not.toHaveTextContent("store: clickhouse");
await user.click(screen.getByText("Set up manually"));

View file

@ -5,7 +5,6 @@ import { useState } from "react";
import { useTimeout } from "usehooks-ts";
import { cn } from "@/lib/cva.config";
import { LensPreviewButton } from "../../ui/LensPreviewButton";
import { Button } from "@/components/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { copyToClipboard } from "@/utils/dataUtils";
@ -683,7 +682,6 @@ export function TracingSetupCard(props: TracingSetupProps) {
return (
<div className="w-full max-w-3xl pb-8" data-testid="tracing-setup-card">
{!props.connected && <LensPreviewButton />}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<h2 className="text-xl font-semibold tracking-tight">{setupTitle(enabled, props.connected ?? false)}</h2>
<span role="status" className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">

View file

@ -1,13 +1,9 @@
"use client";
import { useId, type ReactNode } from "react";
import { type ReactNode } from "react";
import { Activity, ArrowUpRight } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import { StatusDot } from "@/components/shared/StatusDot";
import { useStoredValue } from "@/lib/storage";
import { LENS_INTRO_DISMISSED } from "../storage";
import { WorkerSettings } from "./worker/WorkerSettings";
import { SettingsCard, SettingsSection } from "./SettingsSection";
import type { LensList } from "../model/types";
@ -44,21 +40,6 @@ function TracingSection({ enabled, onOpenTraces }: { enabled: boolean; onOpenTra
);
}
function IntroductionSection() {
const [dismissed, setDismissed] = useStoredValue(LENS_INTRO_DISMISSED);
const id = useId();
return (
<SettingsSection heading="Introduction" description="The getting started dialog shown when you open Lens.">
<SettingsCard className="flex items-center justify-between gap-3">
<Label htmlFor={id} className="text-sm font-normal">
Show the introduction on each new session
</Label>
<Switch id={id} checked={!dismissed} onCheckedChange={(show) => setDismissed(!show)} />
</SettingsCard>
</SettingsSection>
);
}
export function LensSettings({
list,
workerReadyAction,
@ -78,7 +59,6 @@ export function LensSettings({
>
<WorkerSettings workers={list.workers} readyAction={workerReadyAction} />
</SettingsSection>
<IntroductionSection />
</div>
);
}

View file

@ -1,6 +1 @@
import { z } from "zod";
import { storageKey } from "@/lib/storage";
export const FINDING_PANEL_WIDTH_KEY = "litellm.lens.findingPanelWidth";
export const LENS_INTRO_DISMISSED = storageKey("local", "lens.intro.dismissed", z.boolean(), false);
export const LENS_INTRO_SEEN = storageKey("session", "lens.intro.seen", z.boolean(), false);

View file

@ -82,17 +82,19 @@ export function RunHeader({ trace, handoff, onBack, embedded }: RunHeaderProps)
const { summary } = trace;
const failed = summary.status === "error";
return (
<header className="flex shrink-0 flex-col gap-2 border-b bg-background px-4 pt-3 pb-2.5">
<div className="flex min-w-0 items-center gap-2">
{!embedded && (
<Button variant="ghost" size="icon-xs" onClick={onBack} aria-label="Back to runs">
<ArrowLeft className="size-4" />
</Button>
)}
<h1 className="min-w-0 truncate text-base font-semibold">{traceDisplayName(summary)}</h1>
<IdChip value={summary.trace_id} label="Copy trace ID" />
<RunIcon summary={summary} failed={failed} />
<div className="ml-auto flex shrink-0 items-center gap-2">
<header className="@container/run-header flex shrink-0 flex-col gap-2 border-b bg-background px-4 pt-3 pb-2.5">
<div className="flex min-w-0 flex-col gap-2 @xl/run-header:flex-row @xl/run-header:items-center">
<div className="flex min-w-0 flex-1 items-center gap-2">
{!embedded && (
<Button variant="ghost" size="icon-xs" onClick={onBack} aria-label="Back to runs">
<ArrowLeft className="size-4" />
</Button>
)}
<h1 className="min-w-0 flex-1 truncate text-base font-semibold">{traceDisplayName(summary)}</h1>
<IdChip value={summary.trace_id} label="Copy trace ID" />
<RunIcon summary={summary} failed={failed} />
</div>
<div className="flex shrink-0 flex-wrap items-center justify-between gap-2 @xl/run-header:ml-auto">
<TabsList aria-label="Trace view" className="group-data-horizontal/tabs:h-7">
<TabsTrigger value="steps" className="px-2.5 text-xs">
Steps

View file

@ -7,7 +7,6 @@ import { ApiError } from "@/lib/http/client";
import { renderWithProviders, testQueryClient } from "../../../../../tests/test-utils";
import traceList from "../__fixtures__/trace_list.json";
import { LensPreviewContext } from "../../ui/LensPreviewButton";
import AgentTracesPage from "./AgentTracesPage";
import { filterRuns } from "./runSearch/runQuery";
import { AgentTracesSection, type TimeControls } from "./AgentTracesSection";
@ -40,16 +39,14 @@ const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
const renderSection = () =>
renderWithProviders(
<LensPreviewContext.Provider value={{ target: document.body, open: vi.fn() }}>
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail={false}
/>
</LensPreviewContext.Provider>,
<AgentTracesSection
accessToken="sk-test"
isActive
startTime="2026-09-29T00:00"
endTime="2026-09-30T00:00"
isCustomDate={false}
isLiveTail={false}
/>,
);
// A UTC-pinned day around the fixture runs (2026-09-30 ~06:43 UTC), so they land in the same bucket in any timezone.
@ -180,7 +177,7 @@ describe("AgentTracesSection", () => {
const card = await screen.findByTestId("tracing-setup-card");
expect(card).toHaveTextContent("Tracing is not enabled");
expect(screen.getByRole("button", { name: "Preview sample" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
expect(card).toHaveTextContent("type: clickhouse");
expect(card).toHaveTextContent("url: os.environ/CLICKHOUSE_URL");
expect(screen.getByRole("button", { name: "Check setup" })).toBeEnabled();
@ -196,7 +193,7 @@ describe("AgentTracesSection", () => {
const card = await screen.findByTestId("tracing-setup-card");
expect(card).toHaveTextContent("Connect your agent");
expect(card).toHaveTextContent("Waiting for your first trace");
expect(screen.getByRole("button", { name: "Preview sample" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
expect(card).not.toHaveTextContent("store: clickhouse");
});

View file

@ -225,6 +225,7 @@ export function AgentTracesTable({
const tableOptions: TableOptions<TraceSummary> = {
data: traces,
columns: RUN_COLUMNS,
defaultColumn: { size: undefined },
getRowId: runKey,
autoResetAll: false,
state: { columnVisibility },

View file

@ -31,10 +31,10 @@ export function RunSearch({ value, onChange, runs, range }: RunSearchProps) {
value={value}
onValueChange={onChange}
label="Search runs"
className="h-full min-w-0"
className="h-11 min-w-0"
>
<SearchBox.Input
className="h-full rounded-none border-0 px-3 focus-within:bg-muted/40 focus-within:ring-0 dark:bg-transparent"
className="h-full overflow-hidden rounded-none border-0 px-3 whitespace-nowrap focus-within:bg-muted/40 focus-within:ring-0 dark:bg-transparent"
placeholder="Search runs, or filter like agent:researcher status:error"
/>
<SearchBox.Suggestions>

View file

@ -17,9 +17,15 @@ interface RunsToolbarProps {
export function RunsToolbar({ query, onQueryChange, runs, range, children }: RunsToolbarProps) {
return (
<div className="flex h-10 shrink-0 items-stretch border-b border-border bg-card">
<RunSearch value={query} onChange={onQueryChange} runs={runs} range={range} />
{children && <div className="flex shrink-0 items-stretch">{children}</div>}
<div className="@container/traces shrink-0 border-b border-border bg-card">
<div className="grid grid-cols-1 @4xl/traces:grid-cols-[minmax(0,1fr)_auto]">
<RunSearch value={query} onChange={onQueryChange} runs={runs} range={range} />
{children && (
<div className="flex min-h-11 min-w-0 items-stretch justify-end border-t border-border @4xl/traces:border-t-0">
{children}
</div>
)}
</div>
</div>
);
}

View file

@ -1,24 +0,0 @@
"use client";
import { createContext, useContext } from "react";
import { createPortal } from "react-dom";
import { Button } from "@/components/ui/button";
export interface LensPreview {
/** Header slot for portaled actions; null until it mounts. */
readonly target: HTMLElement | null;
readonly open?: () => void;
}
export const LensPreviewContext = createContext<LensPreview | undefined>(undefined);
export function LensPreviewButton() {
const preview = useContext(LensPreviewContext);
if (!preview?.open || !preview.target) return null;
return createPortal(
<Button variant="outline" onClick={preview.open}>
Preview sample
</Button>,
preview.target,
);
}

View file

@ -1,15 +1,23 @@
import { cva } from "@/lib/cva.config";
export const frameCard = cva("flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl bg-card", {
variants: { session: { live: "border border-foreground/15", demo: "border-2 border-info" } },
defaultVariants: { session: "live" },
});
export const frameCard = cva(
"flex min-h-0 flex-1 flex-col overflow-hidden rounded-b-2xl bg-card @min-[24rem]/lens-frame:rounded-t-2xl",
{
variants: {
session: {
live: "border border-t-0 border-foreground/15 @min-[24rem]/lens-frame:border-t",
demo: "border-2 border-t-0 border-info @min-[24rem]/lens-frame:border-t-2",
},
},
defaultVariants: { session: "live" },
},
);
export const frameTab = cva("relative z-raised rounded-t-2xl bg-card", {
variants: {
session: {
live: "-mb-px border-x border-t border-foreground/15",
demo: "-mb-0.5 border-x-2 border-t-2 border-info",
live: "border-x border-t border-foreground/15 @min-[24rem]/lens-frame:-mb-px",
demo: "border-x-2 border-t-2 border-info @min-[24rem]/lens-frame:-mb-0.5",
},
},
defaultVariants: { session: "live" },

View file

@ -246,8 +246,8 @@ function Input({ placeholder, className, ...props }: SearchBoxInputProps) {
<div className="relative flex min-w-0 flex-1">
<ProseMirrorDoc />
{!text && (
<span className="pointer-events-none absolute inset-y-0 left-0 flex items-center font-mono text-xs text-muted-foreground">
{placeholder}
<span className="pointer-events-none absolute inset-0 flex items-center font-mono text-xs text-muted-foreground">
<span className="truncate">{placeholder}</span>
</span>
)}
</div>

View file

@ -50,16 +50,18 @@ export function TimeRangeControls({
showLive = true,
onLiveChange,
}: TimeRangeControlsProps) {
const label = fixedRange ? fixedRangeLabel(fixedRange) : presetLabel(rangeHours);
return (
<div className="flex items-stretch divide-x divide-border border-l border-border">
<div className="flex min-w-0 max-w-full items-stretch divide-x divide-border border-l border-border">
<DropdownMenu>
<DropdownMenuTrigger
aria-label="Time range"
className={cn(SEGMENT, "text-foreground hover:bg-muted/60")}
className={cn(SEGMENT, "min-w-0 text-foreground hover:bg-muted/60")}
title={label}
data-testid="time-range-trigger"
>
<span className="tabular-nums">{fixedRange ? fixedRangeLabel(fixedRange) : presetLabel(rangeHours)}</span>
<ChevronDown className="size-3.5 text-muted-foreground" />
<span className="truncate tabular-nums">{label}</span>
<ChevronDown className="size-3.5 shrink-0 text-muted-foreground" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-auto min-w-44">
<DropdownMenuRadioGroup
@ -81,6 +83,7 @@ export function TimeRangeControls({
onClick={() => onLiveChange(!live)}
className={cn(
SEGMENT,
"shrink-0",
live ? "bg-info/10 text-info hover:bg-info/15" : "text-muted-foreground hover:text-foreground",
)}
>

View file

@ -1,6 +1,6 @@
import { describe, expect, it } from "vitest";
import { bandForWindow, dragUpdate, formatSpan, type Bucket } from "./Timeline";
import { bandForWindow, dragUpdate, formatSpan, timelineTicks, type Bucket } from "./Timeline";
const HOUR = 3600 * 1000;
const START = Date.UTC(2026, 8, 30, 0, 0, 0);
@ -53,3 +53,17 @@ describe("bandForWindow", () => {
expect(bandForWindow(buckets, null)).toBeNull();
});
});
describe("timelineTicks", () => {
it("keeps both edges visible and reduces labels in narrow timelines", () => {
const range = { startMs: 0, endMs: 24 * HOUR };
const narrow = timelineTicks(range, 280);
const wide = timelineTicks(range, 1000);
expect(narrow[0]).toBe(0);
expect(narrow.at(-1)).toBe(1);
expect(narrow.length).toBeLessThan(wide.length);
expect(280 / (narrow.length - 1)).toBeGreaterThanOrEqual(80);
expect(timelineTicks(range, 0)).toEqual([0, 1]);
expect(timelineTicks({ ...range, endMs: 30 * 24 * HOUR }, 280)).toEqual([0, 1]);
});
});

View file

@ -183,9 +183,15 @@ function SelectionBracket({
);
}
function TickAxis({ range }: { range: TimeWindow }) {
export function timelineTicks(range: TimeWindow, width: number): number[] {
const labelWidth = tickFormat(range) === EDGE_FORMAT ? 140 : 80;
const count = Math.max(2, Math.min(TICKS, Math.floor(width / labelWidth)));
return Array.from({ length: count }, (_, i) => i / (count - 1));
}
function TickAxis({ range, width }: { range: TimeWindow; width: number }) {
const format = tickFormat(range);
const ticks = Array.from({ length: TICKS }, (_, i) => i / (TICKS - 1));
const ticks = timelineTicks(range, width);
return (
<div className="relative mt-0.5 h-5">
{ticks.map((t) => (
@ -314,7 +320,7 @@ export function Timeline({ buckets, selection, onSelect }: TimelineProps) {
)}
</DotFieldRoot>
<div className="mt-1">
<TickAxis range={range} />
<TickAxis range={range} width={stripWidth} />
</div>
{hover !== null && !drag && <BucketTooltip bucket={buckets[hover]} index={hover} bucketCount={bucketCount} />}
{selection && (

View file

@ -2,8 +2,6 @@ import type { ReactElement } from "react";
import { vi } from "vitest";
import { LensServicesProvider, liveLensServices } from "@/components/lens/data/LensServices";
import { OnboardingProvider, type Onboarding } from "@/components/lens/onboarding/OnboardingContext";
import { LENS_INTRO_DISMISSED } from "@/components/lens/storage";
import { writeStorage } from "@/lib/storage";
import { renderWithProviders } from "./test-utils";
export interface StubbedRequest {
@ -67,11 +65,6 @@ export function stubGateway() {
return gateway;
}
/** The Lens introduction opens on a first visit; tests about anything else start with it dismissed. */
export function dismissLensIntro() {
writeStorage(LENS_INTRO_DISMISSED, true);
}
type LensRenderOptions = Parameters<typeof renderWithProviders>[1] & {
accessToken?: string;
onboarding?: Partial<Onboarding>;