feat(lens): guide setup through the first investigation

This commit is contained in:
moe-berri 2026-10-03 18:16:24 -07:00
parent 8520626e7a
commit 06a50d055a
11 changed files with 933 additions and 94 deletions

View file

@ -1,7 +1,7 @@
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "@/../tests/test-utils";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import LensPage from "@/app/(dashboard)/lens/page";
const { auth } = vi.hoisted(() => ({ auth: vi.fn() }));
@ -16,32 +16,44 @@ vi.mock("./investigations/InvestigationsView", () => ({
}));
describe("Lens navigation", () => {
beforeEach(() => auth.mockReturnValue({ accessToken: "test-token", userRole: "Admin", isViewOnly: false }));
beforeEach(() => {
testQueryClient.clear();
auth.mockReturnValue({ accessToken: "test-token", userRole: "Admin", isViewOnly: false });
vi.stubGlobal(
"fetch",
vi.fn(async (input) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/v1/traces") return Response.json({ data: [{}] });
if (path === "/lens") return Response.json({ lenses: [], workers: [], tracing_enabled: true });
return Response.json({ traces: true, requests: false, data: [] });
}),
);
});
it("opens traces by default and pauses polling while viewing investigations", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<LensPage />, { onUrlUpdate });
expect(screen.getByRole("tab", { name: "Traces" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Trace polling active")).toBeVisible();
expect(await screen.findByText("Trace polling active")).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(screen.getByText("Manage investigations")).toBeVisible();
expect(await screen.findByText("Manage investigations")).toBeVisible();
expect(screen.getByText("Trace polling paused")).not.toBeVisible();
expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("tab")).toBe("investigations");
await user.click(screen.getByRole("tab", { name: "Traces" }));
expect(screen.getByText("Trace polling active")).toBeVisible();
expect(await screen.findByText("Trace polling active")).toBeVisible();
});
it("opens existing lens links on findings", () => {
it("opens existing lens links on findings", async () => {
renderWithProviders(<LensPage />, { searchParams: "?lens=saved-lens" });
expect(screen.getByRole("tab", { name: "Findings" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Manage investigations")).toBeVisible();
expect(await screen.findByText("Manage investigations")).toBeVisible();
});
it("honors an explicit traces tab even when a saved investigation is in the URL", () => {
it("honors an explicit traces tab even when a saved investigation is in the URL", async () => {
renderWithProviders(<LensPage />, { searchParams: "?tab=traces&lens=saved-lens" });
expect(screen.getByRole("tab", { name: "Traces" })).toHaveAttribute("aria-selected", "true");
expect(screen.getByText("Trace polling active")).toBeVisible();
expect(await screen.findByText("Trace polling active")).toBeVisible();
});
it.each(["Internal User", "Internal Viewer", "Org Admin"])(
@ -50,7 +62,7 @@ describe("Lens navigation", () => {
auth.mockReturnValue({ accessToken: "test-token", userRole, isViewOnly: false });
const user = userEvent.setup();
renderWithProviders(<LensPage />);
expect(screen.getByText("Trace polling active")).toBeVisible();
expect(await screen.findByText("Trace polling active")).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(screen.getByText(/Investigations require proxy administrator access/)).toBeVisible();
expect(screen.queryByText("Manage investigations")).not.toBeInTheDocument();
@ -60,10 +72,10 @@ describe("Lens navigation", () => {
it.each([
{ userRole: "Admin Viewer", isViewOnly: false },
{ userRole: "Admin", isViewOnly: true },
])("preserves read-only investigation access for $userRole with isViewOnly=$isViewOnly", (session) => {
])("preserves read-only investigation access for $userRole with isViewOnly=$isViewOnly", async (session) => {
auth.mockReturnValue({ accessToken: "test-token", ...session });
renderWithProviders(<LensPage />, { searchParams: "?tab=investigations" });
expect(screen.getByText("Read-only investigations")).toBeVisible();
expect(await screen.findByText("Read-only investigations")).toBeVisible();
expect(screen.queryByText("Manage investigations")).not.toBeInTheDocument();
});
});

View file

@ -0,0 +1,191 @@
import { 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 { LensWorkspace } from "./LensWorkspace";
import { createLensDemoData } from "./demo/createLensDemo";
import type { LensList } from "./model/types";
const network = vi.fn<typeof fetch>();
const list = vi.fn<() => Promise<LensList>>();
const data = createLensDemoData();
const worker = () => ({
id: "setup-worker",
analysis_key_id: "a".repeat(64),
revoked: false,
last_seen: new Date().toISOString(),
scope: data.lenses[0].scope,
});
function serve({ enabled = false, traces = false, requests = false, connected = false } = {}) {
list.mockResolvedValue({ lenses: [], workers: connected ? [worker()] : [], tracing_enabled: enabled });
network.mockImplementation(async (input, init) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/v1/traces")
return enabled
? Response.json({ data: traces ? [data.runs[0].trace.summary] : [] })
: Response.json({ detail: "Tracing is not enabled" }, { status: 501 });
if (path === "/lens/activity/available") return Response.json({ traces, requests });
if (path === "/lens" && init?.method === "POST") {
const saved = { ...data.lenses[0], settings: { ...data.lenses[0].settings, ...JSON.parse(String(init.body)) } };
list.mockResolvedValue({ lenses: [saved], workers: [worker()], tracing_enabled: true });
return Response.json(saved);
}
if (path === "/lens") return Response.json(await list());
if (path === "/key/generate") return Response.json({ token_id: worker().analysis_key_id });
if (path === "/lens/workers/register") {
list.mockResolvedValue({ lenses: [], workers: [worker()], tracing_enabled: true });
return Response.json({ worker: worker(), token: "test-worker-token", image: "test-worker-image" });
}
if (path === "/models") return Response.json({ data: [{ id: "analysis" }] });
if (path === "/model_group/info")
return Response.json({ data: [{ model_group: "analysis", providers: ["OpenAI"], mode: "chat" }] });
if (path === "/key/info") return Response.json({ info: { models: ["analysis"], max_budget: 100 } });
if (path === "/lens/agents") return Response.json(["support_agent"]);
if (path === "/lens/preview/sample") return Response.json({ eligible: 1, selected: 1, executions: [] });
if (path.endsWith("/runs")) return Response.json(data.lenses[0].jobs);
return Response.json({ data: [] });
});
}
beforeEach(() => {
testQueryClient.clear();
network.mockReset();
list.mockReset();
vi.stubGlobal("fetch", network);
Element.prototype.scrollIntoView = vi.fn();
serve();
});
describe("Lens setup journey", () => {
it("shares the introduction across tabs and stays in setup after the first trace", async () => {
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, { onUrlUpdate });
expect(await screen.findByRole("heading", { name: "Before you start" })).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(screen.getByRole("heading", { name: "Before you start" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "Set up Lens" }));
await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("setup")).toBe("lens"));
serve({ enabled: true });
await user.click(screen.getByRole("button", { name: "Check setup" }));
expect(await screen.findByText("Trace storage is connected")).toBeVisible();
await user.click(screen.getByRole("button", { name: "Continue to your agent" }));
expect(screen.getByRole("button", { name: "Check for traces" })).toBeVisible();
serve({ enabled: true, traces: true });
await user.click(screen.getByRole("button", { name: "Check for traces" }));
expect(await screen.findByText(/Your first trace is ready/)).toBeVisible();
expect(screen.queryByRole("table", { name: "Agent runs" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Continue to worker" }));
const connection = within(await screen.findByRole("dialog", { name: "Connect a worker" }));
await user.click(connection.getByRole("combobox", { name: "Analysis model" }));
await user.click(await screen.findByRole("option", { name: "analysis" }));
await user.click(connection.getByRole("button", { name: "Get install command" }));
const connected = within(await screen.findByRole("dialog", { name: "Worker connected" }));
await user.click(connected.getByRole("button", { name: "New investigation" }));
expect(await screen.findByRole("dialog", { name: "Which activity should we investigate?" })).toBeVisible();
expect(screen.getByRole("heading", { name: "Get Lens running", hidden: true })).toBeInTheDocument();
});
it("resumes setup after a reload and leaves only when the user chooses traces", async () => {
serve({ enabled: true, traces: true });
const user = userEvent.setup();
renderWithProviders(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, {
searchParams: "?tab=investigations&setup=lens",
});
expect(await screen.findByRole("button", { name: "Connect worker" })).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Traces" }));
expect(screen.getByRole("heading", { name: "Get Lens running" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "View traces" }));
expect(await screen.findByRole("table", { name: "Agent runs" })).toBeVisible();
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(await screen.findByRole("heading", { name: "Run your first investigation" })).toBeVisible();
expect(screen.queryByRole("link", { name: "Set up traces" })).not.toBeInTheDocument();
expect(screen.queryByRole("heading", { name: "Get Lens running" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Set up Lens" }));
expect(await screen.findByRole("heading", { name: "Get Lens running" })).toBeVisible();
expect(screen.getByRole("button", { name: "Connect worker" })).toBeVisible();
});
it("allows request-only investigations without forcing agent instrumentation", async () => {
serve({ enabled: true, requests: true });
const user = userEvent.setup();
renderWithProviders(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, {
searchParams: "?tab=investigations",
});
expect(await screen.findByText("Request logs received")).toBeVisible();
expect(screen.getByRole("button", { name: "Connect worker" })).toBeEnabled();
expect(screen.queryByRole("heading", { name: "Before you start" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Set up Lens" }));
expect(await screen.findByRole("heading", { name: "Before you start" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "View investigations" }));
expect(await screen.findByRole("heading", { name: "Run your first investigation" })).toBeVisible();
});
it("keeps setup recoverable when checking for a first trace fails", async () => {
serve({ enabled: true });
const user = userEvent.setup();
renderWithProviders(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, {
searchParams: "?setup=lens",
});
await screen.findByRole("button", { name: "Check for traces" });
const normal = network.getMockImplementation()!;
network.mockImplementation(async (input, init) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/v1/traces") return Response.json({ detail: "Trace storage unavailable" }, { status: 503 });
return normal(input, init);
});
await user.click(screen.getByRole("button", { name: "Check for traces" }));
expect(await screen.findByRole("alert")).toHaveTextContent("Could not check setup");
expect(screen.queryByRole("button", { name: "Continue to worker" })).not.toBeInTheDocument();
serve({ enabled: true, traces: true });
await user.click(screen.getByRole("button", { name: "Retry" }));
expect(await screen.findByRole("button", { name: "Continue to worker" })).toBeEnabled();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps administrator-only setup unavailable to trace viewers", async () => {
serve({ enabled: true, traces: true });
const user = userEvent.setup();
renderWithProviders(<LensWorkspace accessToken="setup-token" userRole="Internal User" readOnly={false} />, {
searchParams: "?setup=lens",
});
expect(await screen.findByText(/A gateway administrator can connect a worker/)).toBeVisible();
expect(screen.getByRole("button", { name: "Connect worker" })).toBeDisabled();
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(screen.getByRole("button", { name: "Connect worker" })).toBeDisabled();
expect(network.mock.calls.some(([input]) => new URL(String(input), "http://localhost").pathname === "/lens")).toBe(
false,
);
});
it("opens the saved investigation directly when guided setup finishes", async () => {
serve({ enabled: true, traces: true, connected: true });
const user = userEvent.setup();
const onUrlUpdate = vi.fn();
renderWithProviders(<LensWorkspace accessToken="setup-token" userRole="Admin" readOnly={false} />, {
searchParams: "?setup=lens",
onUrlUpdate,
});
await user.click(await screen.findByRole("button", { name: "New investigation" }));
const dialog = within(screen.getByRole("dialog"));
fireEvent.change(dialog.getByRole("textbox", { name: "Investigation name" }), {
target: { value: "My first review" },
});
await user.click(dialog.getByRole("button", { name: "Continue" }));
await user.click(dialog.getByRole("button", { name: "Continue" }));
await waitFor(() => expect(dialog.getByRole("button", { name: "Run and monitor" })).toBeEnabled());
await user.click(dialog.getByRole("button", { name: "Run and monitor" }));
expect(await screen.findByRole("heading", { name: "My first review" })).toBeVisible();
expect(screen.queryByRole("heading", { name: "Get Lens running" })).not.toBeInTheDocument();
expect(
within(screen.getByRole("tablist", { name: "Lens" })).getByRole("tab", { name: "Findings" }),
).toHaveAttribute("aria-selected", "true");
await waitFor(() => expect(onUrlUpdate.mock.lastCall?.[0].searchParams.get("setup")).toBeNull());
const create = network.mock.calls.find(
([input, init]) => new URL(String(input), "http://localhost").pathname === "/lens" && init?.method === "POST",
);
expect(create).toBeDefined();
expect(JSON.parse(String(create?.[1]?.body))).toEqual(expect.objectContaining({ name: "My first review" }));
});
});

View file

@ -25,7 +25,7 @@ describe("Lens interactive demo", () => {
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Internal User" readOnly={false} />, {
onUrlUpdate,
});
expect(await screen.findByText("Enable tracing")).toBeVisible();
expect(await screen.findByRole("heading", { name: "The gateway that helps your agents improve" })).toBeVisible();
await user.click(screen.getByRole("button", { name: "Preview sample" }));
expect(await screen.findByText("Where is order #1042?")).toBeVisible();
expect(screen.getByText("You’re viewing demo data")).toBeVisible();
@ -46,7 +46,7 @@ describe("Lens interactive demo", () => {
expect(network).not.toHaveBeenCalled();
expect(onUrlUpdate).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Exit demo" }));
expect(await screen.findByText("Enable tracing")).toBeVisible();
expect(await screen.findByRole("heading", { name: "The gateway that helps your agents improve" })).toBeVisible();
expect(screen.queryByText("You’re viewing demo data")).not.toBeInTheDocument();
expect(screen.queryByText("Can I return my headphones?")).not.toBeInTheDocument();
});
@ -84,7 +84,7 @@ describe("Lens interactive demo", () => {
expect(network).not.toHaveBeenCalled();
expect(onUrlUpdate).not.toHaveBeenCalled();
await user.click(screen.getByRole("button", { name: "Exit demo" }));
expect(await screen.findByRole("heading", { name: "Find what needs attention" })).toBeVisible();
expect(await screen.findByRole("heading", { name: "The gateway that helps your agents improve" })).toBeVisible();
});
it("has no demo entry for existing investigations, populated traces, or connecting another agent", async () => {

View file

@ -1,7 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { Aperture } from "lucide-react";
import { Aperture, Loader2 } from "lucide-react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { parseAsString, parseAsStringLiteral, useQueryState } from "nuqs";
import AgentTracesPage from "@/components/view_logs/TraceView/AgentTracesPage";
@ -13,6 +13,9 @@ import { LensPreviewTarget } from "./LensPreviewButton";
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
import { InvestigationsView } from "./investigations/InvestigationsView";
import { createLensDemo } from "./demo/createLensDemo";
import { Button } from "@/components/ui/button";
import { LensGettingStarted } from "./setup/LensGettingStarted";
import { useLensSetup, type LensSetupState } from "./setup/useLensSetup";
type Tab = "traces" | "findings" | "investigations";
type WorkspaceProps = { accessToken: string; userRole: string; readOnly: boolean };
@ -61,12 +64,51 @@ function LensContent({
"tab",
parseAsStringLiteral(["traces", "findings", "investigations"]).withOptions({ history: "push" }),
);
const [lensId] = useQueryState("lens", parseAsString);
const [lensId, setLensId] = useQueryState("lens", parseAsString);
const [setup, setSetup] = useQueryState("setup", parseAsStringLiteral(["lens"]).withOptions({ history: "push" }));
const [demoTab, setDemoTab] = useState(initialTab);
const [previewTarget, setPreviewTarget] = useState<HTMLDivElement | null>(null);
const defaultTab = lensId ? "findings" : "traces";
const activeTab = demo ? demoTab : tab ?? defaultTab;
const openDemo = onDemo ? () => onDemo(activeTab) : undefined;
const canInvestigate = isProxyAdminTierRole(userRole);
const setupState = useLensSetup(accessToken, !demo, canInvestigate, setup === "lens");
const setupLocation = { tab: activeTab, canInvestigate, selected: !!lensId, requested: setup === "lens" };
const showSetup = !demo && needsSetup(setupState, setupLocation);
const showSetupButton = !demo && !showSetup && isProxyAdminRole(userRole);
const startSetup = () => {
if (setup !== "lens") void setSetup("lens");
};
const showTraces = () => {
void setSetup(null);
void setTab(setupState.tracesReady ? "traces" : "investigations");
};
const checkingSetup = !demo && setupState.loading;
const showCreated = (id: string) => {
void setSetup(null);
void setLensId(id);
void setTab("findings");
};
const sharedSetup = checkingSetup ? (
<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>
) : (
<TabsContent value={activeTab} keepMounted className="min-w-0">
<LensGettingStarted
accessToken={accessToken}
state={setupState}
readOnly={readOnly}
canInvestigate={isProxyAdminRole(userRole)}
canMintTracingKey={isProxyAdminRole(userRole)}
onStart={startSetup}
onExit={showTraces}
onCreated={showCreated}
onDemo={openDemo}
/>
</TabsContent>
);
return (
<LensPreviewTarget.Provider value={previewTarget}>
<main className="flex min-h-full w-full min-w-0 flex-1 flex-col gap-2 px-3 pt-2 pb-3">
@ -94,36 +136,65 @@ function LensContent({
</TabsTrigger>
</TabsList>
</div>
<div ref={setPreviewTarget} />
</div>
<TabsContent value="traces" keepMounted className="flex min-h-0 flex-col">
<AgentTracesPage
accessToken={accessToken}
isActive={activeTab === "traces"}
readOnly={readOnly}
canMintTracingKey={!demo && isProxyAdminRole(userRole)}
onDemo={activeTab === "traces" ? openDemo : undefined}
/>
</TabsContent>
{(["findings", "investigations"] as const).map((view) => (
<TabsContent key={view} value={view} keepMounted={!!demo} className="flex min-h-0 flex-col">
{demo || isProxyAdminTierRole(userRole) ? (
<InvestigationsView
view={view}
active={activeTab === view}
accessToken={accessToken}
readOnly={readOnly || !isProxyAdminRole(userRole)}
onDemo={activeTab === view ? openDemo : undefined}
/>
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
<div className="flex items-center gap-2">
{showSetupButton && (
<Button variant="outline" size="sm" onClick={startSetup}>
Set up Lens
</Button>
)}
</TabsContent>
))}
<div ref={setPreviewTarget} />
</div>
</div>
{checkingSetup || showSetup ? (
sharedSetup
) : (
<>
<TabsContent value="traces" keepMounted className="flex min-h-0 flex-col">
<AgentTracesPage
accessToken={accessToken}
isActive={activeTab === "traces"}
readOnly={readOnly}
canMintTracingKey={!demo && isProxyAdminRole(userRole)}
onDemo={activeTab === "traces" ? openDemo : undefined}
/>
</TabsContent>
{(["findings", "investigations"] as const).map((view) => (
<TabsContent key={view} value={view} keepMounted={!!demo} className="flex min-h-0 flex-col">
{demo || isProxyAdminTierRole(userRole) ? (
<InvestigationsView
view={view}
active={activeTab === view}
accessToken={accessToken}
readOnly={readOnly || !isProxyAdminRole(userRole)}
onDemo={activeTab === view ? openDemo : undefined}
/>
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</TabsContent>
))}
</>
)}
</Tabs>
</main>
</LensPreviewTarget.Provider>
);
}
function needsSetup(
state: LensSetupState,
{
tab,
canInvestigate,
selected,
requested,
}: { tab: Tab; canInvestigate: boolean; selected: boolean; requested: boolean },
) {
if (requested) return true;
if (!state.missingTraces) return false;
if (tab === "traces") return true;
const hasActivity = state.hasInvestigations || state.requestsReady || selected;
return canInvestigate && !hasActivity;
}

View file

@ -303,12 +303,10 @@ it("guides a first-time administrator into worker connection and lens setup", as
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" onDemo={vi.fn()} />);
const guide = within(await screen.findByRole("region", { name: "Find what needs attention" }));
const guide = within(await screen.findByRole("region", { name: "Run your first investigation" }));
expect(apiClient.get).toHaveBeenCalledWith("/lens/activity/available", { accessToken: "test" });
expect(await guide.findByRole("link", { name: "View traces" })).toHaveAttribute(
"href",
expect.stringMatching(/^\/ui\/lens\/?\?tab=traces$/),
);
expect(guide.getByText("Traces received")).toBeVisible();
expect(guide.queryByRole("link", { name: "View traces" })).not.toBeInTheDocument();
expect(await guide.findByRole("button", { name: "Preview sample" })).toBeVisible();
await user.click(guide.getByRole("button", { name: "Connect worker" }));
const connection = within(await screen.findByRole("dialog", { name: "Connect a worker" }));

View file

@ -34,36 +34,45 @@ export function InvestigationsWelcome({
const canConnect = activityReady && !readOnly;
const traceStatus = activityStatus(tracesReady, requestsReady, checking);
const waitingForWorker = activityReady && !connected;
const firstStepTitle = requestsReady && !tracesReady ? "Recorded activity" : "Set up traces";
const stepOffset = Number(!activityReady);
const traceButtonClass = buttonVariants({
variant: activityReady ? "ghost" : "default",
variant: "default",
className: "col-start-2 w-fit sm:col-start-auto",
});
return (
<section aria-labelledby="lens-welcome" className="max-w-3xl pb-6">
{onDemo && <LensPreviewButton onClick={onDemo} />}
<h2 id="lens-welcome" className="text-xl font-semibold tracking-tight">
Find what needs attention
{activityReady ? "Run your first investigation" : "Find what needs attention"}
</h2>
<p className="mt-2 max-w-lg text-sm leading-6 text-muted-foreground">
Check how your agents behave. Get findings you can trace back to what happened.
{activityReady
? "Your recorded activity is ready. Connect a worker and choose what Lens should look for."
: "Check how your agents behave. Get findings you can trace back to what happened."}
</p>
{activityReady && (
<p role="status" className="mt-3 text-sm text-success">
{traceStatus}
</p>
)}
<ol className="mt-8 divide-y border-y">
<li className="grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 py-5 sm:grid-cols-[28px_minmax(0,1fr)_auto]">
<Step number={1} complete={activityReady} checking={checking} active={!activityReady} />
<div>
<h3 className="text-sm font-medium">{firstStepTitle}</h3>
<p className="mt-1 text-sm text-muted-foreground">{traceStatus}</p>
</div>
<Link href={uiHref("lens/?tab=traces")} className={traceButtonClass}>
{tracesReady ? "View traces" : "Set up traces"}
</Link>
</li>
{!activityReady && (
<li className="grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 py-5 sm:grid-cols-[28px_minmax(0,1fr)_auto]">
<Step number={1} complete={activityReady} checking={checking} active={!activityReady} />
<div>
<h3 className="text-sm font-medium">Set up traces</h3>
<p className="mt-1 text-sm text-muted-foreground">{traceStatus}</p>
</div>
<Link href={uiHref("lens/?tab=traces")} className={traceButtonClass}>
Set up traces
</Link>
</li>
)}
<li
data-state={activityReady ? "active" : "inactive"}
className="grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 py-5 text-muted-foreground data-[state=active]:text-foreground sm:grid-cols-[28px_minmax(0,1fr)_auto]"
>
<Step number={2} complete={workerReady} active={waitingForWorker} />
<Step number={stepOffset + 1} complete={workerReady} active={waitingForWorker} />
<div>
<h3 className="text-sm font-medium">Connect a worker</h3>
<p className="mt-1 text-sm text-muted-foreground">
@ -83,7 +92,7 @@ export function InvestigationsWelcome({
data-state={workerReady ? "active" : "inactive"}
className="grid grid-cols-[28px_minmax(0,1fr)] items-center gap-x-4 gap-y-3 py-5 text-muted-foreground data-[state=active]:text-foreground sm:grid-cols-[28px_minmax(0,1fr)_auto]"
>
<Step number={3} complete={false} active={workerReady} />
<Step number={stepOffset + 2} complete={false} active={workerReady} />
<div>
<h3 className="text-sm font-medium">Run an investigation</h3>
</div>

View file

@ -0,0 +1,226 @@
"use client";
import { useRef, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Check, ShieldCheck } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
import { lensQueries } from "../api/queries";
import { useSaveLens } from "../api/mutations";
import { useLensApi } from "../services";
import { TraceSheet } from "../investigations/TraceSheet";
import { LensPreviewButton } from "../LensPreviewButton";
import { useAnalysisKeyInfo } from "./worker/AnalysisKeyDetails";
import { WorkerDialog } from "./worker/WorkerDialog";
import { InvestigationSetupDialog } from "./InvestigationSetupDialog";
import { LensIntroduction } from "./LensIntroduction";
import type { LensSetupState } from "./useLensSetup";
import type { Settings } from "../model/types";
import { initialSetupStep, LensSetupSteps } from "./LensSetupSteps";
export function LensGettingStarted({
accessToken,
state,
readOnly,
canInvestigate,
canMintTracingKey,
onStart,
onExit,
onCreated,
onDemo,
}: {
accessToken: string;
state: LensSetupState;
readOnly: boolean;
canInvestigate: boolean;
canMintTracingKey: boolean;
onStart: () => void;
onExit: () => void;
onCreated: (id: string) => void;
onDemo?: () => void;
}) {
const setupRef = useRef<HTMLElement>(null);
const [step, setStep] = useState(() => initialSetupStep(state));
const [workerOpen, setWorkerOpen] = useState(false);
const [investigationOpen, setInvestigationOpen] = useState(false);
const [trace, setTrace] = useState<TraceSummary | null>(null);
const selectStep = (index: number) => {
onStart();
setStep(index);
setupRef.current?.querySelector<HTMLButtonElement>(`[aria-controls="lens-setup-step-${index}"]`)?.focus();
};
const canLeave = state.tracesReady || state.requestsReady || state.hasInvestigations;
const start = () => {
onStart();
setupRef.current?.scrollIntoView({ block: "start" });
setupRef.current?.focus({ preventScroll: true });
};
const connectWorker = () => {
selectStep(2);
setWorkerOpen(true);
};
const createInvestigation = () => {
selectStep(3);
setInvestigationOpen(true);
};
return (
<div className="mx-auto w-full max-w-6xl space-y-8 py-4">
{onDemo && <LensPreviewButton onClick={onDemo} />}
<LensIntroduction onStart={start} onDemo={onDemo} />
<div className="grid items-start gap-6 lg:grid-cols-[minmax(0,1fr)_260px]">
<section
ref={setupRef}
tabIndex={-1}
aria-labelledby="lens-setup-title"
className="min-w-0 scroll-mt-4 outline-none"
onFocusCapture={onStart}
>
<div className="mb-5 flex flex-wrap items-center justify-between gap-3">
<div>
<h2 id="lens-setup-title" className="text-xl font-semibold tracking-tight">
Get Lens running
</h2>
<p className="mt-1 text-sm text-muted-foreground">
We’ll check each connection. Stay here until your first investigation is ready.
</p>
</div>
{canLeave && (
<Button variant="outline" size="sm" onClick={onExit}>
{state.tracesReady ? "View traces" : "View investigations"}
</Button>
)}
</div>
<LensSetupSteps
step={step}
state={state}
accessToken={accessToken}
readOnly={readOnly}
canInvestigate={canInvestigate}
canMintTracingKey={canMintTracingKey}
onStep={selectStep}
onConnect={connectWorker}
onCreate={createInvestigation}
onTrace={setTrace}
/>
{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>
<Button variant="outline" size="sm" onClick={state.refresh} disabled={state.checking}>
Retry
</Button>
</div>
)}
{(readOnly || !canInvestigate) && (
<p className="mt-4 text-sm text-muted-foreground">
A gateway administrator can connect a worker and run investigations.
</p>
)}
</section>
<aside className="rounded-lg border p-5" aria-labelledby="lens-prerequisites">
<h2 id="lens-prerequisites" className="text-base font-medium">
Before you start
</h2>
<ul className="mt-4 space-y-4 text-sm text-muted-foreground">
{[
"Access to your LiteLLM gateway configuration",
"ClickHouse for trace storage",
"A server with Docker for the worker",
"An analysis model on your gateway",
].map((item) => (
<li key={item} className="flex gap-2">
<Check aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
{item}
</li>
))}
</ul>
<div className="mt-5 flex gap-2 border-t pt-5 text-xs leading-5 text-muted-foreground">
<ShieldCheck aria-hidden="true" className="mt-0.5 size-4 shrink-0" />
<p>
Your infrastructure stores the traces. Investigation content is sent to your selected model provider
through the gateway.
</p>
</div>
</aside>
</div>
{workerOpen && (
<WorkerDialog
accessToken={accessToken}
workers={state.workers}
onClose={() => setWorkerOpen(false)}
onChanged={state.refresh}
onReady={
state.ready
? () => {
setWorkerOpen(false);
createInvestigation();
}
: undefined
}
/>
)}
{investigationOpen && (
<FirstInvestigation
accessToken={accessToken}
state={state}
onClose={() => setInvestigationOpen(false)}
onCreated={onCreated}
/>
)}
{trace && (
<TraceSheet
open
traceId={trace.trace_id}
traceRef={trace.trace_ref}
accessToken={accessToken}
onClose={() => setTrace(null)}
/>
)}
</div>
);
}
function FirstInvestigation({
accessToken,
state,
onClose,
onCreated,
}: {
accessToken: string;
state: LensSetupState;
onClose: () => void;
onCreated: (id: string) => void;
}) {
const api = useLensApi(accessToken);
const saveLens = useSaveLens(accessToken);
const models = useQuery({ ...lensQueries.models(api) });
const modelDetails = useQuery({ ...lensQueries.modelDetails(api) });
const workers = state.workers.filter((worker) => !worker.revoked);
const analysisAccess = useAnalysisKeyInfo(
accessToken,
workers.length === 1 ? workers[0].analysis_key_id ?? undefined : undefined,
);
const defaultModel = analysisAccess.data?.models.length === 1 ? analysisAccess.data.models[0] : undefined;
const save = async (settings: Settings) => {
if (!state.ready)
throw new Error("Wait for recorded activity and a connected worker before starting an investigation");
const saved = await saveLens.mutateAsync({ settings });
state.refresh();
onCreated(saved.id);
};
return (
<InvestigationSetupDialog
accessToken={accessToken}
ready={state.ready}
defaultSource={!state.tracesReady && state.requestsReady ? "requests" : "traces"}
defaultModel={defaultModel}
models={models.data?.data.map((model) => model.id) ?? []}
modelDetails={modelDetails.data?.data ?? []}
modelsLoading={models.isLoading}
modelsError={models.error?.message}
onClose={onClose}
onSave={save}
/>
);
}

View file

@ -0,0 +1,71 @@
import { Aperture, ArrowRight, ArrowUpRight, GitBranch } from "lucide-react";
import { Button } from "@/components/ui/button";
import { scenarios } from "../demo/scenarios";
export function LensIntroduction({ onStart, onDemo }: { onStart: () => void; onDemo?: () => void }) {
const example = scenarios[1];
return (
<section aria-labelledby="lens-introduction" className="rounded-lg border bg-card p-5 sm:p-8">
<h2 id="lens-introduction" className="max-w-2xl text-2xl font-semibold tracking-tight sm:text-3xl">
The gateway that helps your agents improve
</h2>
<p className="mt-3 max-w-2xl text-sm leading-6 text-muted-foreground">
Lens reviews your agents’ recorded activity and finds behavior worth fixing. Each finding links to the original
steps, so you can see what happened and what to change next.
</p>
<div className="mt-6 flex flex-wrap items-center gap-3">
{onDemo && (
<Button onClick={onDemo}>
Explore with sample data <ArrowRight aria-hidden="true" className="size-4" />
</Button>
)}
<Button variant={onDemo ? "outline" : "default"} onClick={onStart}>
Set up Lens
</Button>
<a
href="https://docs.litellm.ai/docs/proxy/lens"
target="_blank"
rel="noreferrer"
className="inline-flex items-center gap-1 px-2 text-sm text-muted-foreground underline-offset-4 hover:underline"
>
Docs <ArrowUpRight aria-hidden="true" className="size-3.5" />
</a>
</div>
<div className="mt-8 grid overflow-hidden rounded-lg border lg:grid-cols-2">
<div className="border-b bg-muted/20 p-5 lg:border-r lg:border-b-0">
<div className="flex flex-wrap items-center justify-between gap-2 text-sm">
<h3 className="flex items-center gap-2 font-medium">
<GitBranch aria-hidden="true" className="size-4" />
Agent trace
</h3>
<span className="text-xs text-muted-foreground">Sample · {example.agent}</span>
</div>
<dl className="mt-5 space-y-4 text-sm">
<div>
<dt className="font-mono text-xs text-muted-foreground">{example.tool}</dt>
<dd className="mt-1.5 leading-6">{example.result}</dd>
</div>
<div className="border-t pt-4">
<dt className="text-xs text-muted-foreground">Final answer</dt>
<dd className="mt-1.5 leading-6">“{example.answer}”</dd>
</div>
</dl>
</div>
<div className="p-5">
<h3 className="flex items-center gap-2 text-sm font-medium">
<Aperture aria-hidden="true" className="size-4" />
Lens finding
</h3>
<h4 className="mt-5 text-base font-medium">Performance claim has no supporting benchmark</h4>
<p className="mt-2 text-sm leading-6 text-muted-foreground">
The answer claims a 40% improvement, but the retrieved documentation contains no comparative benchmark.
</p>
<p className="mt-4 border-t pt-4 text-sm leading-6">
<span className="font-medium">Suggested change: </span>Require a benchmark source for numerical performance
claims, or remove the comparison.
</p>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,201 @@
import { ArrowRight, Check, ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import { TracingSetupFields } from "@/components/view_logs/TraceView/TracingSetupCard";
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
import type { LensSetupState } from "./useLensSetup";
type StepProps = {
accessToken: string;
state: LensSetupState;
readOnly: boolean;
canInvestigate: boolean;
canMintTracingKey: boolean;
onStep: (step: number) => void;
onTrace: (trace: TraceSummary) => void;
onConnect: () => void;
onCreate: () => void;
};
export function initialSetupStep(state: LensSetupState) {
if (!state.tracingEnabled) return 0;
if (!state.tracesReady) return 1;
return state.connected ? 3 : 2;
}
function StorageStep({ state, onStep, ...props }: StepProps) {
if (!state.tracingEnabled)
return (
<TracingSetupFields
detail="Tracing is not enabled"
accessToken={props.accessToken}
onOpenTrace={props.onTrace}
onCheck={state.refresh}
checking={state.checking}
readOnly={props.readOnly}
/>
);
return (
<div className="space-y-4">
<p role="status" className="text-sm text-success">
Trace storage is connected
</p>
<Button onClick={() => onStep(1)}>
Continue to your agent <ArrowRight aria-hidden="true" className="size-4" />
</Button>
</div>
);
}
function continuationLabel(state: LensSetupState) {
if (state.connected) return "Continue to investigation";
return state.tracesReady ? "Continue to worker" : "Continue with request logs";
}
function AgentStep({ state, onConnect, onCreate, ...props }: StepProps) {
if (!state.tracingEnabled)
return <p className="text-sm text-muted-foreground">Connect trace storage in step 1 before sending a trace.</p>;
const activityReady = state.tracesReady || state.requestsReady;
return (
<>
<div hidden={state.tracesReady}>
<TracingSetupFields
detail={null}
accessToken={props.accessToken}
onOpenTrace={(value) => {
state.refresh();
props.onTrace(value);
}}
onCheck={state.refresh}
checking={state.checking}
readOnly={props.readOnly}
canMintTracingKey={props.canMintTracingKey}
/>
</div>
{state.tracesReady && (
<p role="status" className="text-sm text-success">
Your first trace is ready. Continue setup so Lens can investigate your agent’s behavior.
</p>
)}
{activityReady && (
<div className="mt-4">
<Button onClick={state.connected ? onCreate : onConnect} disabled={props.readOnly || !props.canInvestigate}>
{continuationLabel(state)}
<ArrowRight aria-hidden="true" className="size-4" />
</Button>
{state.requestsReady && !state.tracesReady && (
<p className="mt-2 text-xs text-muted-foreground">
Request logs are already available. You can investigate them now and add agent traces later.
</p>
)}
</div>
)}
</>
);
}
function WorkerStep({ state, onConnect, onCreate, readOnly, canInvestigate }: StepProps) {
const activityReady = state.tracesReady || state.requestsReady;
return (
<div className="space-y-4">
<p role="status" className="text-sm text-muted-foreground">
{state.connected
? "Worker connected. You’re ready to create an investigation."
: "The worker reviews recorded activity using a model on your gateway. You choose its analysis model and spending limit."}
</p>
{!activityReady && (
<p className="text-sm text-muted-foreground">Record activity in step 2 before connecting a worker.</p>
)}
<Button onClick={state.connected ? onCreate : onConnect} disabled={!activityReady || readOnly || !canInvestigate}>
{state.connected ? "Continue to investigation" : "Connect worker"}
<ArrowRight aria-hidden="true" className="size-4" />
</Button>
</div>
);
}
function InvestigationStep({ state, onCreate, readOnly, canInvestigate }: StepProps) {
return (
<div className="space-y-4">
<p className="text-sm leading-6 text-muted-foreground">
Choose the activity to review and describe how your agent should behave. Lens will show findings with evidence
and suggested changes.
</p>
{!state.ready && (
<p className="text-sm text-muted-foreground">
Recorded activity and a connected worker are required before you can run an investigation.
</p>
)}
<Button onClick={onCreate} disabled={!state.ready || readOnly || !canInvestigate}>
New investigation <ArrowRight aria-hidden="true" className="size-4" />
</Button>
</div>
);
}
export function LensSetupSteps({ step, ...props }: StepProps & { step: number }) {
const items = [
{
title: "Enable tracing on the gateway",
description: "Connect ClickHouse and restart the gateway.",
complete: props.state.tracingEnabled,
content: <StorageStep {...props} />,
},
{
title: "Send your first trace",
description: "Capture your agent’s inputs, outputs, and tool calls.",
complete: props.state.tracesReady,
content: <AgentStep {...props} />,
},
{
title: "Connect a worker",
description: "Choose a model and run the worker on your infrastructure.",
complete: props.state.connected,
content: <WorkerStep {...props} />,
},
{
title: "Run your first investigation",
description: "Describe the expected behavior and review a sample of activity.",
complete: props.state.hasInvestigations,
content: <InvestigationStep {...props} />,
},
];
return (
<ol className="divide-y overflow-hidden rounded-lg border">
{items.map((item, index) => (
<li key={item.title}>
<h3>
<button
type="button"
aria-expanded={step === index}
aria-controls={`lens-setup-step-${index}`}
onClick={() => props.onStep(index)}
className="flex w-full items-start gap-3 p-5 text-left hover:bg-muted/30"
>
<span
className={
item.complete
? "flex size-7 shrink-0 items-center justify-center rounded-full bg-success/10 text-success"
: "flex size-7 shrink-0 items-center justify-center rounded-full border text-xs font-medium"
}
aria-label={item.complete ? `Step ${index + 1} complete` : `Step ${index + 1}`}
>
{item.complete ? <Check aria-hidden="true" className="size-4" /> : index + 1}
</span>
<span className="min-w-0 flex-1">
<span className="block text-sm font-medium">{item.title}</span>
<span className="mt-1 block text-sm font-normal text-muted-foreground">{item.description}</span>
</span>
<ChevronDown
aria-hidden="true"
className={`mt-1 size-4 shrink-0 text-muted-foreground ${step === index ? "rotate-180" : ""}`}
/>
</button>
</h3>
<div id={`lens-setup-step-${index}`} hidden={step !== index} className="px-5 pb-5 sm:pl-15">
{item.content}
</div>
</li>
))}
</ol>
);
}

View file

@ -0,0 +1,56 @@
import { useQuery } from "@tanstack/react-query";
import { useNow } from "@/hooks/useNow";
import { isTracingNotEnabled, useTraceAvailability } from "@/components/view_logs/TraceView/useAgentTraces";
import { lensQueries } from "../api/queries";
import { workerConnected } from "../model/status";
import { useLensApi } from "../services";
function traceSetupState(traces: ReturnType<typeof useTraceAvailability>, configured: boolean) {
const disabled = isTracingNotEnabled(traces.error);
return {
tracesReady: traces.data === true && !traces.error,
tracingEnabled: !disabled && (traces.isSuccess || configured),
missingTraces: disabled || traces.data === false,
error: disabled ? null : traces.error,
};
}
export function useLensSetup(accessToken: string, enabled: boolean, canInvestigate: boolean, settingUp: boolean) {
const api = useLensApi(accessToken);
const now = useNow(2000);
const traces = useTraceAvailability(accessToken, enabled);
const list = useQuery({ ...lensQueries.list(api, false, settingUp), enabled: enabled && canInvestigate });
const activity = useQuery(lensQueries.activity(api, enabled && canInvestigate && list.isSuccess, false));
const data = list.data ?? { lenses: [], workers: [], tracing_enabled: false };
const traceState = traceSetupState(traces, data.tracing_enabled);
const requestsReady = activity.data?.requests === true && !activity.error;
const connected = data.workers.some((worker) => workerConnected(worker, now));
const hasInvestigations = data.lenses.length > 0;
const error = traceState.error || list.error || activity.error;
const activityReady = traceState.tracesReady || requestsReady;
const loadingActivity = list.isSuccess && activity.isPending;
const loadingInvestigations = canInvestigate && (list.isPending || loadingActivity);
const refresh = () => {
void traces.refetch();
if (canInvestigate) {
void list.refetch();
void activity.refetch();
}
};
return {
tracingEnabled: traceState.tracingEnabled,
tracesReady: traceState.tracesReady,
requestsReady,
connected,
hasInvestigations,
missingTraces: traceState.missingTraces,
loading: traces.isPending || loadingInvestigations,
checking: traces.isFetching || list.isFetching || activity.isFetching,
ready: activityReady && connected && !error,
workers: data.workers,
error: error?.message,
refresh,
};
}
export type LensSetupState = ReturnType<typeof useLensSetup>;

View file

@ -645,17 +645,7 @@ function ConnectAgent({
);
}
export function TracingSetupCard({
detail,
accessToken,
onOpenTrace,
connected = false,
onCheck,
checking = false,
readOnly = false,
canMintTracingKey = false,
onDemo,
}: {
type TracingSetupProps = {
detail: string | null;
accessToken: string;
onOpenTrace: (trace: TraceSummary) => void;
@ -664,20 +654,47 @@ export function TracingSetupCard({
checking?: boolean;
readOnly?: boolean;
canMintTracingKey?: boolean;
onDemo?: () => void;
}) {
};
export function TracingSetupFields({
detail,
accessToken,
onOpenTrace,
connected = false,
onCheck,
checking = false,
readOnly = false,
canMintTracingKey = false,
}: TracingSetupProps) {
const [checked, setChecked] = useState(false);
const enabled = detail === null;
const check = () => {
setChecked(true);
onCheck?.();
};
return detail === null ? (
<ConnectAgent
accessToken={accessToken}
onOpenTrace={onOpenTrace}
connected={connected}
checked={checked}
checking={checking}
onCheck={check}
readOnly={readOnly}
canMintTracingKey={canMintTracingKey}
/>
) : (
<EnableTracing checked={checked} checking={checking} onCheck={check} />
);
}
export function TracingSetupCard({ onDemo, ...props }: TracingSetupProps & { onDemo?: () => void }) {
const enabled = props.detail === null;
return (
<div className="w-full max-w-3xl pb-8" data-testid="tracing-setup-card">
{onDemo && <LensPreviewButton onClick={onDemo} />}
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<h2 className="text-xl font-semibold tracking-tight">{setupTitle(enabled, connected)}</h2>
<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">
{enabled ? (
<ActiveDot />
@ -700,20 +717,7 @@ export function TracingSetupCard({
? "Send your agent’s runs to LiteLLM to see its inputs, outputs, and tool calls."
: "Tracing needs ClickHouse and a small update to your LiteLLM proxy configuration."}
</p>
{enabled ? (
<ConnectAgent
accessToken={accessToken}
onOpenTrace={onOpenTrace}
connected={connected}
checked={checked}
checking={checking}
onCheck={check}
readOnly={readOnly}
canMintTracingKey={canMintTracingKey}
/>
) : (
<EnableTracing checked={checked} checking={checking} onCheck={check} />
)}
<TracingSetupFields {...props} />
</div>
);
}