feat(ui): center Lens mode switch in a notch joined to the content card

Larger Traces/Investigations switch, a subtle dot when an investigation is running or queued, and a bigger Lens title with a docs link.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Yujong Lee 2026-10-03 18:15:50 -07:00
parent d26c52a6ba
commit 282f36e225
4 changed files with 166 additions and 53 deletions

View file

@ -205,4 +205,25 @@ describe("Lens interactive demo", () => {
expect(await screen.findByRole("table", { name: "Agent runs" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
});
it("marks the Investigations tab while a scan runs and clears it once the scan finishes", async () => {
const saved = createLensDemoData().lenses[0];
const withJob = (status: (typeof saved.jobs)[number]["status"]) => ({
...saved,
jobs: [{ ...saved.jobs[0], status }, ...saved.jobs.slice(1)],
});
const lenses = vi.fn(() => [withJob("running")]);
network.mockImplementation(async (input) => {
const path = new URL(String(input), "http://localhost").pathname;
if (path === "/lens") return Response.json({ lenses: lenses(), workers: [], tracing_enabled: false });
if (path === "/v1/traces") return Response.json({ detail: "Tracing is not enabled" }, { status: 501 });
return Response.json({ data: [], traces: false, requests: false });
});
renderWithProviders(<LensWorkspace accessToken="live-token" userRole="Admin" readOnly={false} />);
const tab = within(screen.getByRole("tablist", { name: "Lens" })).getByRole("tab", { name: "Investigations" });
await waitFor(() => expect(tab).toHaveAccessibleDescription("An investigation is running"));
lenses.mockReturnValue([withJob("completed")]);
await testQueryClient.refetchQueries({ queryKey: ["lens", "list"] });
await waitFor(() => expect(tab).toHaveAccessibleDescription(""));
});
});

View file

@ -1,8 +1,9 @@
"use client";
import { useId, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { Tabs as TabsPrimitive } from "@base-ui/react/tabs";
import { Activity, Aperture, Info, ScanSearch } from "lucide-react";
import { Activity, Aperture, ArrowUpRight, Info, ScanSearch } from "lucide-react";
import AgentTracesPage from "@/components/view_logs/TraceView/AgentTracesPage";
import { Switch } from "@/components/ui/switch";
import { Tabs, TabsContent } from "@/components/ui/tabs";
@ -11,6 +12,10 @@ import { LensPreviewContext } from "./LensPreviewButton";
import { isProxyAdminRole, isProxyAdminTierRole } from "@/utils/roles";
import { InvestigationsView } from "./investigations/InvestigationsView";
import { createLensDemo } from "./demo/createLensDemo";
import { lensQueries } from "./api/queries";
import { useLensApi } from "./services";
import { investigationActivity, type InvestigationActivity } from "./model/status";
import { cn } from "@/lib/cva.config";
import { LENS_TABS, useLensRoute, type LensTab } from "./route";
type WorkspaceProps = { accessToken: string; userRole: string; readOnly: boolean };
@ -47,79 +52,148 @@ function DemoToggle({ demo, onChange }: { demo: boolean; onChange: (demo: boolea
const MODE_ICONS = { traces: Activity, investigations: ScanSearch } as const;
function LensModeSwitch() {
const ACTIVITY_DOT: Record<Exclude<InvestigationActivity, "idle">, { className: string; label: string }> = {
running: { className: "bg-[#3b5bfd] motion-safe:animate-pulse", label: "An investigation is running" },
queued: { className: "bg-muted-foreground/60", label: "An investigation is queued" },
};
function ActivityDot({ activity }: { activity: InvestigationActivity }) {
if (activity === "idle") return null;
return (
<TabsPrimitive.List
aria-label="Lens"
className="relative inline-flex h-8 items-center rounded-full border border-border bg-muted/60 p-0.5"
>
<TabsPrimitive.Indicator className="absolute top-0.5 bottom-0.5 left-(--active-tab-left) w-(--active-tab-width) rounded-full bg-background shadow-sm ring-1 ring-border transition-[left,width] duration-200 ease-out motion-reduce:transition-none" />
{Object.entries(LENS_TABS).map(([view, label]) => {
const Icon = MODE_ICONS[view as LensTab];
return (
<TabsPrimitive.Tab
key={view}
value={view}
className="relative z-raised inline-flex h-full items-center gap-1.5 rounded-full px-3 text-xs font-medium text-muted-foreground outline-none transition-colors hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring/50 data-active:text-foreground"
>
<Icon aria-hidden="true" className="size-3.5" />
{label}
</TabsPrimitive.Tab>
);
})}
</TabsPrimitive.List>
<span
aria-hidden="true"
className={cn(
"size-1.5 rounded-full animate-in fade-in-0 zoom-in-50 duration-300",
ACTIVITY_DOT[activity].className,
)}
/>
);
}
function NotchCorner({ side }: { side: "left" | "right" }) {
return (
<svg
aria-hidden="true"
width="13"
height="12"
viewBox="0 0 13 12"
className={cn(
"pointer-events-none absolute bottom-0 overflow-visible",
side === "left" ? "-left-3" : "-right-3 -scale-x-100",
)}
>
<path d="M0 11 A12.5 11 0 0 0 12.5 0 H13 V12 H0 Z" className="fill-card" />
<path d="M0 11.5 A12.5 11.5 0 0 0 12.5 0" className="fill-none stroke-border" />
</svg>
);
}
function LensModeSwitch({ activity }: { activity: InvestigationActivity }) {
return (
<div className="relative z-raised -mb-px rounded-t-2xl border-x border-t border-border bg-card px-1.5 pt-1.5 pb-[7px]">
<NotchCorner side="left" />
<NotchCorner side="right" />
<TabsPrimitive.List
aria-label="Lens"
className="relative inline-flex h-9 items-center rounded-full bg-muted/70 p-1"
>
<TabsPrimitive.Indicator className="absolute top-1 bottom-1 left-(--active-tab-left) w-(--active-tab-width) rounded-full bg-background shadow-sm ring-1 ring-border transition-[left,width] duration-300 ease-[cubic-bezier(0.32,0.72,0,1)] motion-reduce:transition-none" />
{Object.entries(LENS_TABS).map(([view, label]) => {
const Icon = MODE_ICONS[view as LensTab];
return (
<TabsPrimitive.Tab
key={view}
value={view}
aria-description={
view === "investigations" && activity !== "idle" ? ACTIVITY_DOT[activity].label : undefined
}
className="relative z-raised inline-flex h-full items-center gap-2 rounded-full px-4 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"
>
<Icon aria-hidden="true" className="size-4" />
{label}
{view === "investigations" && <ActivityDot activity={activity} />}
</TabsPrimitive.Tab>
);
})}
</TabsPrimitive.List>
</div>
);
}
function useInvestigationActivity(accessToken: string, enabled: boolean): InvestigationActivity {
const api = useLensApi(accessToken);
const { data } = useQuery({ ...lensQueries.list(api, false), enabled });
return investigationActivity(data?.lenses ?? []);
}
const PANEL =
"flex min-h-0 flex-1 flex-col overflow-y-auto animate-in fade-in-0 duration-300 motion-reduce:animate-none";
function LensContent({ accessToken, userRole, readOnly }: WorkspaceProps) {
const { tab, lensId, demo, setTab, setDemo } = useLensRoute();
const [previewTarget, setPreviewTarget] = useState<HTMLDivElement | null>(null);
const activeTab = tab ?? (lensId ? "investigations" : "traces");
const canInvestigate = isProxyAdminTierRole(userRole);
const activity = useInvestigationActivity(accessToken, canInvestigate);
const preview = (view: LensTab) => ({
target: previewTarget,
open: !demo && activeTab === view ? () => setDemo(true) : undefined,
});
return (
<main className="flex h-full w-full min-w-0 flex-1 flex-col gap-2 px-3 pt-2 pb-3">
<Tabs value={activeTab} onValueChange={(value) => setTab(value as LensTab)} className="min-h-0 flex-1 gap-2">
<div className="flex min-h-8 flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<h1 className="flex items-center gap-1.5 text-sm font-semibold tracking-tight">
<Aperture aria-hidden="true" className="size-4" strokeWidth={2} />
<main className="flex h-full w-full min-w-0 flex-1 flex-col px-3 pt-2 pb-3">
<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-0.5 self-center">
<h1 className="flex items-center gap-2 text-lg font-semibold tracking-tight">
<Aperture aria-hidden="true" className="size-5" strokeWidth={2} />
Lens
</h1>
<LensModeSwitch />
<p className="truncate text-xs text-muted-foreground">
Trace your agents and investigate what goes wrong.{" "}
<a
href="https://docs.litellm.ai/docs/proxy/lens"
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-0.5 font-medium text-foreground/80 underline-offset-2 hover:text-foreground hover:underline"
>
Docs
<ArrowUpRight aria-hidden="true" className="size-3" />
</a>
</p>
</div>
<div className="flex items-center gap-3">
<LensModeSwitch activity={activity} />
<div className="flex min-w-0 flex-wrap items-center justify-end gap-3 self-center">
<div ref={setPreviewTarget} />
<DemoToggle demo={demo} onChange={setDemo} />
</div>
</div>
<TabsContent value="traces" keepMounted className="flex min-h-0 flex-col overflow-y-auto">
<LensPreviewContext.Provider value={preview("traces")}>
<AgentTracesPage
accessToken={accessToken}
isActive={activeTab === "traces"}
readOnly={readOnly}
canMintTracingKey={isProxyAdminRole(userRole)}
/>
</LensPreviewContext.Provider>
</TabsContent>
<TabsContent value="investigations" className="flex min-h-0 flex-col overflow-y-auto">
<LensPreviewContext.Provider value={preview("investigations")}>
{isProxyAdminTierRole(userRole) ? (
<InvestigationsView
active={activeTab === "investigations"}
<div className="flex min-h-0 flex-1 flex-col rounded-2xl border border-border bg-card p-3">
<TabsContent value="traces" keepMounted className={PANEL}>
<LensPreviewContext.Provider value={preview("traces")}>
<AgentTracesPage
accessToken={accessToken}
readOnly={readOnly || !isProxyAdminRole(userRole)}
isActive={activeTab === "traces"}
readOnly={readOnly}
canMintTracingKey={isProxyAdminRole(userRole)}
/>
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</LensPreviewContext.Provider>
</TabsContent>
</LensPreviewContext.Provider>
</TabsContent>
<TabsContent value="investigations" className={PANEL}>
<LensPreviewContext.Provider value={preview("investigations")}>
{canInvestigate ? (
<InvestigationsView
active={activeTab === "investigations"}
accessToken={accessToken}
readOnly={readOnly || !isProxyAdminRole(userRole)}
/>
) : (
<p className="py-6 text-sm text-muted-foreground">
Investigations require proxy administrator access. You can still view your traces.
</p>
)}
</LensPreviewContext.Provider>
</TabsContent>
</div>
</Tabs>
</main>
);

View file

@ -1,4 +1,4 @@
import { nextCheckStatus, workerConnected } from "./status";
import { investigationActivity, nextCheckStatus, workerConnected } from "./status";
import { describe, expect, it } from "vitest";
import type { Job, Lens, LensList } from "./types";
@ -102,3 +102,12 @@ it("shows the actual next schedule and avoids a stale countdown during active sc
);
expect(nextCheckStatus(lens, now)).toBeNull();
});
it("summarizes activity across investigations, preferring a running scan over a queued one", () => {
const withStatus = (status: Job["status"]): Lens => ({ ...lens, jobs: [{ ...job, status }] });
expect(investigationActivity([])).toBe("idle");
expect(investigationActivity([lens, withStatus("failed")])).toBe("idle");
expect(investigationActivity([lens, withStatus("queued")])).toBe("queued");
expect(investigationActivity([withStatus("queued"), withStatus("running")])).toBe("running");
expect(investigationActivity([{ ...lens, jobs: [lens.jobs[0], { ...job, status: "running" }] }])).toBe("running");
});

View file

@ -43,3 +43,12 @@ export function readiness(
const ready = activityReady && connected && !listError;
return { tracesReady, requestsReady, activityReady, ready };
}
export type InvestigationActivity = "running" | "queued" | "idle";
export function investigationActivity(lenses: readonly Lens[]): InvestigationActivity {
const statuses = new Set(lenses.flatMap((lens) => lens.jobs.map((job) => job.status)));
if (statuses.has("running")) return "running";
if (statuses.has("queued")) return "queued";
return "idle";
}