mirror of
https://github.com/BerriAI/litellm.git
synced 2026-10-07 02:59:05 +00:00
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:
parent
d80f8c28ca
commit
a99bccacea
32 changed files with 345 additions and 419 deletions
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
);
|
||||
}
|
||||
|
|
@ -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" },
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue