Merge remote-tracking branch 'origin/main' into litellm_shared_rust_pagination

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Yujong Lee 2026-10-04 02:24:26 +00:00
commit 9c9bc4ecd3
58 changed files with 2513 additions and 1029 deletions

View file

@ -99,6 +99,11 @@ const eslintConfig = [
],
rules: { "local/no-ad-hoc-z-index": ["error", { allowPopupLayer: true }] },
},
{
files: ["src/components/view_logs/TraceView/**/*.tsx", "src/components/lens/**/*.tsx"],
ignores: ["src/**/*.test.tsx"],
rules: { "local/no-arbitrary-design-value": "error" },
},
{
files: ["tests/eslint-rules/**/*.{ts,tsx}"],
rules: { "local/no-noop-hover-variant": "off", "local/no-ad-hoc-z-index": "off" },

View file

@ -12,7 +12,14 @@ const nextConfig = {
? {
async rewrites() {
return {
beforeFiles: [{ source: "/ui/:path*", destination: "/:path*" }],
beforeFiles: [
{
source: "/:path*",
has: [{ type: "header", key: "content-type", value: "application/json.*" }],
destination: `${devProxyUrl}/:path*`,
},
{ source: "/ui/:path*", destination: "/:path*" },
],
fallback: [{ source: "/:path*", destination: `${devProxyUrl}/:path*` }],
};
},

View file

@ -10,6 +10,7 @@
"dependencies": {
"@anthropic-ai/sdk": "0.92.0",
"@base-ui/react": "^1.6.0",
"@handlewithcare/react-prosemirror": "3.2.9",
"@headlessui/tailwindcss": "0.2.2",
"@heroicons/react": "1.0.6",
"@hookform/resolvers": "5.4.0",
@ -17,6 +18,7 @@
"@tanstack/react-pacer": "0.22.1",
"@tanstack/react-query": "5.100.7",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.13",
"@types/papaparse": "5.5.2",
"class-variance-authority": "0.7.1",
"clsx": "^2.1.1",
@ -32,18 +34,24 @@
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4",
"papaparse": "5.5.3",
"prosemirror-model": "1.25.12",
"prosemirror-state": "1.4.4",
"prosemirror-view": "1.42.3",
"react": "19.2.8",
"react-copy-to-clipboard": "5.1.1",
"react-dom": "19.2.8",
"react-error-boundary": "6.1.6",
"react-hook-form": "7.82.0",
"react-intersection-observer": "11.0.1",
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-reconciler": "0.33.0",
"react-syntax-highlighter": "15.6.6",
"recharts": "3.9.2",
"remark-gfm": "4.0.1",
"sonner": "2.0.8",
"tailwind-merge": "3.4.0",
"usehooks-ts": "3.1.1",
"uuid": "14.0.0",
"zod": "4.6.5"
},
@ -1344,6 +1352,40 @@
"integrity": "sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww==",
"license": "MIT"
},
"node_modules/@handlewithcare/react-prosemirror": {
"version": "3.2.9",
"resolved": "https://registry.npmjs.org/@handlewithcare/react-prosemirror/-/react-prosemirror-3.2.9.tgz",
"integrity": "sha512-zNGR4BDAvXQGY0ph2ZVK/wDdOr3nL3kTjtXv9Bwwmvzy39Gtooc+vQHSHRtWg0AoN33csJgg0ev78dtoScAR9g==",
"license": "Apache-2.0",
"dependencies": {
"classnames": "^2.5.1"
},
"engines": {
"node": ">=16.9"
},
"peerDependencies": {
"@tiptap/core": "^3.0.0",
"@tiptap/pm": "^3.0.0",
"@tiptap/react": "^3.0.0",
"prosemirror-model": "^1.0.0",
"prosemirror-state": "^1.0.0",
"prosemirror-view": "1.42.3",
"react": ">=17 <20",
"react-dom": ">=17 <20",
"react-reconciler": ">=0.26.1 <=0.33.0"
},
"peerDependenciesMeta": {
"@tiptap/core": {
"optional": true
},
"@tiptap/pm": {
"optional": true
},
"@tiptap/react": {
"optional": true
}
}
},
"node_modules/@headlessui/tailwindcss": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@headlessui/tailwindcss/-/tailwindcss-0.2.2.tgz",
@ -3499,6 +3541,23 @@
"react-dom": ">=16.8"
}
},
"node_modules/@tanstack/react-virtual": {
"version": "3.14.13",
"resolved": "https://registry.npmjs.org/@tanstack/react-virtual/-/react-virtual-3.14.13.tgz",
"integrity": "sha512-JbDTAwtzZ99aOeCrAfW5EsE5KSq5RWh6Af2dtFwyLIIk48Ja7vm6n4axu/43T3vfjFnEGJalxJ1wyYjSQD6bSg==",
"license": "MIT",
"dependencies": {
"@tanstack/virtual-core": "3.17.11"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/@tanstack/store": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/@tanstack/store/-/store-0.11.1.tgz",
@ -3522,6 +3581,16 @@
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/virtual-core": {
"version": "3.17.11",
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.17.11.tgz",
"integrity": "sha512-+ILjvtHup6Y2hzQ6YzwMgX1Q+oQpxEGOXCEsCNaPoIP0VxMbizIBTmYTDtkerkIQS8/CbP1BRuyt8V/8BCsy1g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@testing-library/dom": {
"version": "10.4.1",
"resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz",
@ -5161,6 +5230,12 @@
"url": "https://polar.sh/cva"
}
},
"node_modules/classnames": {
"version": "2.5.1",
"resolved": "https://registry.npmjs.org/classnames/-/classnames-2.5.1.tgz",
"integrity": "sha512-saHYOzhIQs6wy2sVxTM6bUDsQO4F50V9RQ22qBpEdCW+I+/Wmke2HOl6lS6dTpdxVhb88/I6+Hs+438c3lfUow==",
"license": "MIT"
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -8450,6 +8525,12 @@
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT"
},
"node_modules/lodash.merge": {
"version": "4.6.2",
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
@ -9966,6 +10047,12 @@
"node": ">= 0.8.0"
}
},
"node_modules/orderedmap": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
"license": "MIT"
},
"node_modules/own-keys": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz",
@ -10314,6 +10401,46 @@
"url": "https://github.com/sponsors/wooorm"
}
},
"node_modules/prosemirror-model": {
"version": "1.25.12",
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
"license": "MIT",
"dependencies": {
"orderedmap": "^2.0.0"
}
},
"node_modules/prosemirror-state": {
"version": "1.4.4",
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.0.0",
"prosemirror-transform": "^1.0.0",
"prosemirror-view": "^1.27.0"
}
},
"node_modules/prosemirror-transform": {
"version": "1.12.2",
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz",
"integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.21.0"
}
},
"node_modules/prosemirror-view": {
"version": "1.42.3",
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.3.tgz",
"integrity": "sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w==",
"license": "MIT",
"dependencies": {
"prosemirror-model": "^1.25.8",
"prosemirror-state": "^1.0.0",
"prosemirror-transform": "^1.1.0"
}
},
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@ -10379,6 +10506,21 @@
"react": "^19.2.8"
}
},
"node_modules/react-error-boundary": {
"version": "6.1.6",
"resolved": "https://registry.npmjs.org/react-error-boundary/-/react-error-boundary-6.1.6.tgz",
"integrity": "sha512-CDXPnXDGyFIbkwaaJ6u+xgsRmJhSi6YdgUDW1vnyKHfXp1a9pfAlM+ZET2CDu80/A+8iRcmXN0NSY9BCCoWP5A==",
"license": "MIT",
"peerDependencies": {
"@types/react": "^18.0.0 || ^19.0.0",
"react": "^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-hook-form": {
"version": "7.82.0",
"resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.82.0.tgz",
@ -10455,6 +10597,21 @@
"react": ">=18"
}
},
"node_modules/react-reconciler": {
"version": "0.33.0",
"resolved": "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz",
"integrity": "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.27.0"
},
"engines": {
"node": ">=0.10.0"
},
"peerDependencies": {
"react": "^19.2.0"
}
},
"node_modules/react-redux": {
"version": "9.3.0",
"resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.3.0.tgz",
@ -12117,6 +12274,21 @@
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
}
},
"node_modules/usehooks-ts": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/usehooks-ts/-/usehooks-ts-3.1.1.tgz",
"integrity": "sha512-I4diPp9Cq6ieSUH2wu+fDAVQO43xwtulo+fKEidHUwZPnYImbtkTjzIJYcDcJqxgmX31GVqNFURodvcgHcW0pA==",
"license": "MIT",
"dependencies": {
"lodash.debounce": "^4.0.8"
},
"engines": {
"node": ">=16.15.0"
},
"peerDependencies": {
"react": "^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc"
}
},
"node_modules/uuid": {
"version": "14.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-14.0.0.tgz",

View file

@ -27,6 +27,7 @@
"dependencies": {
"@anthropic-ai/sdk": "0.92.0",
"@base-ui/react": "^1.6.0",
"@handlewithcare/react-prosemirror": "3.2.9",
"@headlessui/tailwindcss": "0.2.2",
"@heroicons/react": "1.0.6",
"@hookform/resolvers": "5.4.0",
@ -34,6 +35,7 @@
"@tanstack/react-pacer": "0.22.1",
"@tanstack/react-query": "5.100.7",
"@tanstack/react-table": "8.21.3",
"@tanstack/react-virtual": "3.14.13",
"@types/papaparse": "5.5.2",
"class-variance-authority": "0.7.1",
"clsx": "^2.1.1",
@ -49,18 +51,24 @@
"openapi-fetch": "^0.17.0",
"openapi-react-query": "^0.5.4",
"papaparse": "5.5.3",
"prosemirror-model": "1.25.12",
"prosemirror-state": "1.4.4",
"prosemirror-view": "1.42.3",
"react": "19.2.8",
"react-copy-to-clipboard": "5.1.1",
"react-dom": "19.2.8",
"react-error-boundary": "6.1.6",
"react-hook-form": "7.82.0",
"react-intersection-observer": "11.0.1",
"react-json-view-lite": "2.5.0",
"react-markdown": "9.1.0",
"react-reconciler": "0.33.0",
"react-syntax-highlighter": "15.6.6",
"recharts": "3.9.2",
"remark-gfm": "4.0.1",
"sonner": "2.0.8",
"tailwind-merge": "3.4.0",
"usehooks-ts": "3.1.1",
"uuid": "14.0.0",
"zod": "4.6.5"
},

View file

@ -4,6 +4,7 @@ import noComplexJsxArrow from "./no-complex-jsx-arrow.mjs";
import filenamePascalCase from "./filename-pascal-case.mjs";
import noNoopHoverVariant from "./no-noop-hover-variant.mjs";
import noAdHocZIndex from "./no-ad-hoc-z-index.mjs";
import noArbitraryDesignValue from "./no-arbitrary-design-value.mjs";
const plugin = {
rules: {
@ -13,6 +14,7 @@ const plugin = {
"filename-pascal-case": filenamePascalCase,
"no-noop-hover-variant": noNoopHoverVariant,
"no-ad-hoc-z-index": noAdHocZIndex,
"no-arbitrary-design-value": noArbitraryDesignValue,
},
};

View file

@ -1,26 +1,7 @@
import { utilityOf } from "./tailwind-utility.mjs";
const AD_HOC_Z = /^-?z-(?:\d+|\[[^\]]*\]|\([^)]*\))$/;
const OPENERS = { "[": "]", "(": ")" };
const utilityOf = (token) => {
const closers = [];
const lastTopLevelColon = [...token].reduce((found, ch, i) => {
if (closers.length > 0 && ch === closers[closers.length - 1]) {
closers.pop();
return found;
}
if (ch in OPENERS) {
closers.push(OPENERS[ch]);
return found;
}
return ch === ":" && closers.length === 0 ? i : found;
}, -1);
return token
.slice(lastTopLevelColon + 1)
.replace(/^!/, "")
.replace(/!$/, "");
};
const classify = (token, allowPopupLayer) => {
const utility = utilityOf(token);
if (AD_HOC_Z.test(utility)) return "adHoc";

View file

@ -0,0 +1,42 @@
import { utilityOf } from "./tailwind-utility.mjs";
const ARBITRARY_SCALE = /^(?:text|tracking|leading|rounded(?:-[a-z]+)?|border(?:-[a-z]+)?)-\[/;
const ARBITRARY_PROPERTY = /^\[[a-z-]+:/;
const isOffending = (token) => {
const utility = utilityOf(token);
return ARBITRARY_SCALE.test(utility) || ARBITRARY_PROPERTY.test(utility);
};
const rule = {
meta: {
type: "problem",
docs: {
description:
"Disallow arbitrary font size, tracking, leading, radius and border values, and arbitrary CSS properties. Use the theme scale so surfaces share one type and shape system.",
},
schema: [],
messages: {
arbitrary:
"`{{token}}` bypasses the theme scale. Use a scale utility (text-xs/sm, leading-*, tracking-*, rounded-sm/md/lg, border/border-2) instead.",
},
},
create(context) {
const check = (node, value) => {
if (typeof value !== "string" || !value.includes("[")) return;
for (const token of value.split(/\s+/).filter(isOffending)) {
context.report({ node, messageId: "arbitrary", data: { token } });
}
};
return {
Literal(node) {
check(node, node.value);
},
TemplateElement(node) {
check(node, node.value.cooked);
},
};
},
};
export default rule;

View file

@ -0,0 +1,20 @@
const OPENERS = { "[": "]", "(": ")" };
export const utilityOf = (token) => {
const closers = [];
const lastTopLevelColon = [...token].reduce((found, ch, i) => {
if (closers.length > 0 && ch === closers[closers.length - 1]) {
closers.pop();
return found;
}
if (ch in OPENERS) {
closers.push(OPENERS[ch]);
return found;
}
return ch === ":" && closers.length === 0 ? i : found;
}, -1);
return token
.slice(lastTopLevelColon + 1)
.replace(/^!/, "")
.replace(/!$/, "");
};

View file

@ -1,7 +1,7 @@
import { fireEvent, screen, waitFor, within } from "@testing-library/react";
import { screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { chooseSelectOption, renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { renderWithProviders, testQueryClient } from "@/../tests/test-utils";
import { LensWorkspace } from "./LensWorkspace";
import { createLensDemoData } from "./demo/createLensDemo";
@ -33,29 +33,27 @@ describe("Lens interactive demo", () => {
expect(await screen.findByText("Enable tracing")).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();
expect(screen.getByRole("switch", { name: "Demo data" })).toBeChecked();
await expectUrl(onUrlUpdate, (url) => expect(url.get("demo")).toBe("true"));
expect(screen.queryByRole("button", { name: "Set up tracing" })).not.toBeInTheDocument();
network.mockClear();
await user.click(screen.getByRole("button", { name: "Refresh" }));
expect(screen.getByText("Where is order #1042?")).toBeVisible();
fireEvent.change(screen.getByPlaceholderText("Search input or trace ID"), { target: { value: "headphones" } });
const search = screen.getByRole("combobox", { name: "Search runs" });
await user.type(search, "headphones");
await waitFor(() => expect(screen.queryByText("Where is order #1042?")).not.toBeInTheDocument());
expect(screen.getByText("Can I return my headphones?")).toBeVisible();
expect(screen.queryByText("Where is order #1042?")).not.toBeInTheDocument();
fireEvent.change(screen.getByRole("textbox", { name: "Search runs" }), { target: { value: "" } });
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by agent" }), "support_agent");
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by status" }), "Failed");
expect(within(screen.getByRole("table", { name: "Agent runs" })).getAllByRole("row")).toHaveLength(4);
await expectUrl(onUrlUpdate, (url) => expect(url.get("agent")).toBe("support_agent"));
await expectUrl(onUrlUpdate, (url) => expect(url.get("status")).toBe("error"));
await user.clear(search);
await user.type(search, "agent:support_agent status:error");
const table = screen.getByRole("table", { name: "Agent runs" });
await waitFor(() => expect(within(table).getAllByRole("row")).toHaveLength(4));
await expectUrl(onUrlUpdate, (url) => expect(url.get("q")).toBe("agent:support_agent status:error"));
await user.click(screen.getByRole("tab", { name: "Investigations" }));
expect(await screen.findByRole("row", { name: /Support quality/ })).toBeVisible();
expect(screen.queryByRole("button", { name: "New investigation" })).not.toBeInTheDocument();
expect(network).not.toHaveBeenCalled();
await user.click(screen.getByRole("switch", { name: "Demo data" }));
expect(await screen.findByText(/Investigations require proxy administrator access/)).toBeVisible();
expect(screen.queryByText("You’re viewing demo data")).not.toBeInTheDocument();
expect(screen.queryByText("Can I return my headphones?")).not.toBeInTheDocument();
expect(screen.getByRole("switch", { name: "Demo data" })).not.toBeChecked();
await expectUrl(onUrlUpdate, (url) => expect([...url.entries()]).toEqual([["tab", "investigations"]]));
@ -68,7 +66,7 @@ describe("Lens interactive demo", () => {
searchParams: "?demo=true",
onUrlUpdate,
});
expect(await screen.findByText("You’re viewing demo data")).toBeVisible();
expect(await screen.findByRole("switch", { name: "Demo data" })).toBeChecked();
await user.click(await screen.findByText("Where is order #1042?"));
const drawer = await screen.findByRole("complementary", { name: "Trace details" });
await expectUrl(onUrlUpdate, (url) => expect(url.get("trace")).toBeTruthy());
@ -185,9 +183,7 @@ describe("Lens interactive demo", () => {
await user.click(within(screen.getByRole("tablist", { name: "Lens" })).getByRole("tab", { name: "Traces" }));
expect(await screen.findByText("Where is order #1042?")).toBeVisible();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Set up tracing" }));
expect(await screen.findByRole("heading", { name: "Connect another agent" })).toBeVisible();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
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 () => {
@ -211,4 +207,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, 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 };
@ -32,94 +37,177 @@ function SampleSession() {
function DemoToggle({ demo, onChange }: { demo: boolean; onChange: (demo: boolean) => void }) {
const id = useId();
return (
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{demo && (
<p role="status" className="flex items-center gap-1.5 font-medium text-info">
<Info aria-hidden="true" className="size-3.5 shrink-0" />
You’re viewing demo data
</p>
)}
<div className={cn("flex items-center gap-2 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} />
<Switch id={id} size="sm" checked={demo} onCheckedChange={onChange} className="data-checked:bg-info" />
</div>
);
}
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-info 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,
)}
/>
);
}
const FRAME = {
live: {
card: "border border-foreground/15",
tab: "-mb-px border-x border-t border-foreground/15",
corner: { left: "border-r border-b", right: "border-l border-b", tone: "border-foreground/15" },
},
demo: {
card: "border-2 border-info",
tab: "-mb-0.5 border-x-2 border-t-2 border-info",
corner: { left: "border-r-2 border-b-2", right: "border-l-2 border-b-2", tone: "border-info" },
},
} as const;
const frameOf = (demo: boolean) => FRAME[demo ? "demo" : "live"];
/** 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 }) {
const { corner } = frameOf(demo);
return (
<span
aria-hidden="true"
className={cn(
"pointer-events-none absolute bottom-0 size-3 overflow-hidden",
side === "left" ? "-left-3" : "-right-3",
)}
>
<span
className={cn(
"block size-full shadow-[0_0_0_12px_var(--card)]",
side === "left" ? "rounded-br-xl" : "rounded-bl-xl",
corner[side],
corner.tone,
)}
/>
</span>
);
}
function LensModeSwitch({ activity, demo }: { activity: InvestigationActivity; demo: boolean }) {
return (
<div className={cn("relative z-raised rounded-t-2xl bg-card px-1.5 pt-1.5 pb-[7px]", frameOf(demo).tab)}>
<NotchCorner side="left" demo={demo} />
<NotchCorner side="right" demo={demo} />
<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-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">
<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} demo={demo} />
<div className="flex min-w-0 flex-wrap items-center justify-end gap-3 pb-3">
<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={cn("flex min-h-0 flex-1 flex-col overflow-hidden rounded-2xl bg-card", frameOf(demo).card)}>
<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={cn(PANEL, "p-4")}>
<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,6 +1,6 @@
"use client";
import { Fragment, useState, type ReactNode } from "react";
import { Fragment, useState, type KeyboardEvent, type ReactNode } from "react";
import {
ChevronRight,
Circle,
@ -26,16 +26,22 @@ import { scopeLabel } from "../model/format";
import { type Finding, type Lens } from "../model/types";
import { useListSearchRoute } from "../route";
const PRIORITY_COLOR = { high: "text-[#e5484d]", medium: "text-amber-500", low: "text-muted-foreground" } as const;
const PRIORITY_COLOR = { high: "text-destructive", medium: "text-amber-500", low: "text-muted-foreground" } as const;
const ROW =
"border-b border-border/60 transition-colors duration-150 hover:bg-trace-row-hover motion-reduce:transition-none";
const META = "truncate text-[11px] text-muted-foreground";
const META = "truncate text-xs text-muted-foreground";
function openOnRowKeyDown(event: KeyboardEvent<HTMLTableRowElement>, open: () => void) {
if (event.target !== event.currentTarget || (event.key !== "Enter" && event.key !== " ")) return;
event.preventDefault();
open();
}
function JobIcon({ lens }: { lens: Lens }) {
const status = lens.jobs[0]?.status;
const className = "size-4 shrink-0";
if (status === "queued" || status === "running")
return <CircleDashed aria-hidden="true" className={cn(className, "text-[#3b5bfd]")} />;
return <CircleDashed aria-hidden="true" className={cn(className, "text-info")} />;
if (status === "failed") return <CircleX aria-hidden="true" className={cn(className, "text-destructive")} />;
if (status === "cancelled")
return <CircleSlash aria-hidden="true" className={cn(className, "text-muted-foreground")} />;
@ -47,7 +53,7 @@ function TwoLine({ meta, title, className }: { meta: ReactNode; title: ReactNode
return (
<span className="flex min-w-0 flex-col gap-0.5">
<span className={META}>{meta}</span>
<span className={cn("truncate text-[13px] text-foreground", className)}>{title}</span>
<span className={cn("truncate text-sm text-foreground", className)}>{title}</span>
</span>
);
}
@ -56,6 +62,7 @@ export function InvestigationList({
lenses,
connected,
readOnly = false,
onOpen,
onEdit,
onRunNow,
onOpenFinding,
@ -63,6 +70,7 @@ export function InvestigationList({
lenses: Lens[];
connected: boolean;
readOnly?: boolean;
onOpen: (id: string) => void;
onEdit: (id: string) => void;
onRunNow: (id: string) => void;
onOpenFinding: (lens: Lens, finding: Finding) => void;
@ -87,7 +95,7 @@ export function InvestigationList({
<Input
aria-label="Search investigations"
placeholder="Search investigations"
className="h-7 bg-background pl-8 text-[12px]"
className="h-7 bg-background pl-8 text-xs"
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
@ -112,9 +120,11 @@ export function InvestigationList({
return (
<Fragment key={lens.id}>
<tr
onClick={readOnly ? undefined : () => onEdit(lens.id)}
onClick={() => onOpen(lens.id)}
onKeyDown={(event) => openOnRowKeyDown(event, () => onOpen(lens.id))}
tabIndex={0}
aria-label={lens.settings.name}
className={cn(ROW, "group h-14", !readOnly && "cursor-pointer")}
className={cn(ROW, "group h-14 cursor-pointer focus-visible:outline-2 focus-visible:outline-ring")}
>
<td className="pl-2">
<span className="flex min-w-0 items-center gap-2">
@ -149,7 +159,7 @@ export function InvestigationList({
</td>
<td
className={cn(
"w-[180px] truncate px-3 text-right text-[12px]",
"w-[180px] truncate px-3 text-right text-xs",
latest?.status === "failed" ? "text-destructive" : "text-muted-foreground",
)}
>
@ -159,14 +169,14 @@ export function InvestigationList({
{findings.length > 0 && (
<span
title={`${findings.length} open ${findings.length === 1 ? "finding" : "findings"}`}
className="inline-flex min-w-5 justify-center rounded-full bg-muted px-1.5 font-mono text-[11px] tabular-nums text-foreground"
className="inline-flex min-w-5 justify-center rounded-full bg-muted px-1.5 font-mono text-xs tabular-nums text-foreground"
>
{findings.length}
</span>
)}
</td>
<td
className="w-[120px] px-3 text-right text-[12px] text-muted-foreground"
className="w-[120px] px-3 text-right text-xs text-muted-foreground"
title={latest ? formatActivityTimestamp(latest.created_at) : undefined}
>
{latest ? agoLabel(Date.parse(latest.created_at), now) : "never run"}
@ -186,12 +196,18 @@ export function InvestigationList({
>
<Play className="size-3.5" />
</button>
<span
aria-hidden="true"
className="inline-flex size-7 items-center justify-center text-muted-foreground/60 group-hover:text-muted-foreground"
<button
type="button"
aria-label={`Edit ${lens.settings.name}`}
title="Edit"
onClick={(event) => {
event.stopPropagation();
onEdit(lens.id);
}}
className="inline-flex size-7 items-center justify-center rounded text-muted-foreground/60 hover:bg-muted hover:text-foreground group-hover:text-muted-foreground"
>
<Pencil className="size-3.5" />
</span>
</button>
</span>
)}
</td>
@ -205,8 +221,10 @@ export function InvestigationList({
<tr
key={finding.id}
onClick={() => onOpenFinding(lens, finding)}
onKeyDown={(event) => openOnRowKeyDown(event, () => onOpenFinding(lens, finding))}
tabIndex={0}
aria-label={finding.title}
className={cn(ROW, "h-12 cursor-pointer")}
className={cn(ROW, "h-12 cursor-pointer focus-visible:outline-2 focus-visible:outline-ring")}
>
<td className="pl-2" title={finding.suggestion ? `Fix: ${finding.suggestion}` : undefined}>
<span className="flex min-w-0 items-center gap-2">
@ -227,12 +245,12 @@ export function InvestigationList({
/>
</span>
</td>
<td className="px-3 text-right text-[12px] text-muted-foreground">
<td className="px-3 text-right text-xs text-muted-foreground">
{runs} {runs === 1 ? "run" : "runs"}
</td>
<td />
<td
className="px-3 text-right text-[12px] text-muted-foreground"
className="px-3 text-right text-xs text-muted-foreground"
title={formatActivityTimestamp(finding.last_seen)}
>
{agoLabel(Date.parse(finding.last_seen), now)}
@ -249,12 +267,10 @@ export function InvestigationList({
</tbody>
</table>
{!shown.length && (
<div className="py-16 text-center text-[12px] text-muted-foreground">
No investigations match your search.
</div>
<div className="py-16 text-center text-xs text-muted-foreground">No investigations match your search.</div>
)}
</div>
<footer className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-[11px] text-muted-foreground">
<footer className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-xs text-muted-foreground">
{shown.length} {shown.length === 1 ? "investigation" : "investigations"} ·{" "}
{lenses.filter((l) => l.settings.enabled).length} watching
</footer>

View file

@ -1,45 +1,127 @@
"use client";
import { Button } from "@/components/ui/button";
import type { ReactNode } from "react";
import { ArrowUpRight, Loader2, SearchX, TriangleAlert } from "lucide-react";
import { Button, buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/cva.config";
import { ApiError } from "@/lib/http/client";
export function InvestigationError({
error,
queryError,
refresh,
const DOCS_URL = "https://docs.litellm.ai/docs/proxy/lens";
function CenteredState({
role,
icon,
tone = "muted",
title,
description,
children,
}: {
error: string;
queryError: unknown;
refresh: () => void;
role: "status" | "alert";
icon: ReactNode;
tone?: "muted" | "destructive";
title: string;
description: ReactNode;
children?: ReactNode;
}) {
const unavailable = queryError instanceof ApiError && queryError.status === 404;
const queryMessage = queryError instanceof Error ? queryError.message : undefined;
return (
<div role="alert" className="rounded-lg border border-destructive/30 p-4 text-sm text-destructive">
{unavailable
? "The Lens API is unavailable. Reload this page to use the current dashboard; if it persists, check the proxy deployment."
: error || queryMessage}
<Button variant="ghost" size="sm" onClick={() => (unavailable ? window.location.reload() : refresh())}>
{unavailable ? "Reload page" : "Retry"}
<div
role={role}
className="m-auto flex max-w-sm flex-col items-center gap-3 py-16 text-center animate-in fade-in-0 duration-300 motion-reduce:animate-none"
>
<span
aria-hidden="true"
className={cn(
"flex size-10 items-center justify-center rounded-full",
tone === "destructive" ? "bg-destructive/10 text-destructive" : "bg-muted text-muted-foreground",
)}
>
{icon}
</span>
<div className="flex flex-col gap-1">
<p className="text-sm font-medium text-foreground">{title}</p>
<p className="text-sm text-muted-foreground">{description}</p>
</div>
{children && <div className="mt-1 flex items-center gap-2">{children}</div>}
</div>
);
}
function DocsLink() {
return (
<a
href={DOCS_URL}
target="_blank"
rel="noopener noreferrer"
className={buttonVariants({ variant: "ghost", size: "sm" })}
>
Lens docs
<ArrowUpRight aria-hidden="true" className="size-3.5" />
</a>
);
}
function loadFailureMessage(queryError: unknown, unavailable: boolean): string {
if (unavailable)
return "This dashboard may be newer than the proxy. Reload the page, and if it persists, check the proxy deployment.";
if (queryError instanceof Error) return queryError.message;
return "Something went wrong while contacting the proxy.";
}
export function InvestigationsLoadFailed({ queryError, refresh }: { queryError: unknown; refresh: () => void }) {
const unavailable = queryError instanceof ApiError && queryError.status === 404;
return (
<CenteredState
role="alert"
tone="destructive"
icon={<TriangleAlert className="size-5" />}
title={unavailable ? "Lens API is unavailable" : "Couldn't load investigations"}
description={loadFailureMessage(queryError, unavailable)}
>
<Button size="sm" onClick={() => (unavailable ? window.location.reload() : refresh())}>
{unavailable ? "Reload page" : "Try again"}
</Button>
<DocsLink />
</CenteredState>
);
}
export function InvestigationError({ message, refresh }: { message: string; refresh: () => void }) {
return (
<div
role="alert"
className="flex items-center justify-between gap-3 rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2 text-sm text-destructive"
>
<span className="min-w-0">{message}</span>
<Button variant="ghost" size="sm" onClick={refresh}>
Retry
</Button>
</div>
);
}
export function InvestigationsLoading() {
return (
<p role="status" className="py-8 text-sm text-muted-foreground">
Loading investigations…
</p>
<CenteredState
role="status"
icon={<Loader2 className="size-5 animate-spin motion-reduce:animate-none" />}
title="Loading investigations…"
description="Fetching your investigations and their latest findings."
/>
);
}
export function InvestigationMissing({ selectLens }: { selectLens: (id: string | null) => void }) {
return (
<div role="alert" className="text-sm">
This investigation was not found.{" "}
<Button variant="link" onClick={() => selectLens(null)}>
<CenteredState
role="alert"
icon={<SearchX className="size-5" />}
title="Investigation not found"
description="It may have been deleted, or the link points to a different proxy."
>
<Button size="sm" onClick={() => selectLens(null)}>
View all investigations
</Button>
</div>
</CenteredState>
);
}

View file

@ -482,6 +482,24 @@ it("allows retrying a failed trace readiness check without treating it as an emp
expect(await screen.findByRole("link", { name: "Set up traces" })).toBeVisible();
});
it("shows a centered failure with a retry when investigations cannot load, then recovers", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
const list = vi
.fn()
.mockRejectedValueOnce(new ApiError("Proxy timed out", 504, {}))
.mockResolvedValue({ lenses: [], workers: [], tracing_enabled: true });
vi.mocked(apiClient.get).mockImplementation(async (path) => (path === "/lens" ? list() : { data: [] }));
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
const alert = await screen.findByRole("alert");
expect(alert).toHaveTextContent("Couldn't load investigations");
expect(alert).toHaveTextContent("Proxy timed out");
await user.click(within(alert).getByRole("button", { name: "Try again" }));
expect(await screen.findByRole("heading", { name: "Find what needs attention" })).toBeVisible();
expect(screen.queryByRole("alert")).not.toBeInTheDocument();
});
it("keeps saved investigations accessible when tracing is disabled", async () => {
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
@ -585,6 +603,8 @@ it("lists investigations without edit or run controls for read-only viewers", as
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens], tracing_enabled: true, workers: [] };
if (path === "/lens/activity/available") return { traces: true, requests: false };
if (path === "/lens/lens/runs") return [];
if (path === "/lens/agents") return [];
return { data: [] };
});
const user = userEvent.setup();
@ -592,6 +612,67 @@ it("lists investigations without edit or run controls for read-only viewers", as
const row = await screen.findByRole("row", { name: lens.settings.name });
expect(within(row).queryByRole("button", { name: /now/ })).not.toBeInTheDocument();
await user.click(row);
expect(await screen.findByRole("heading", { level: 2, name: lens.settings.name })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});
it("opens investigations from the keyboard without treating nested edit keys as row activation", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [lens], tracing_enabled: true, workers: [] };
if (path === "/lens/activity/available") return { traces: true, requests: false };
if (path === "/lens/lens/runs") return [];
if (path === "/lens/agents") return [];
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
const row = await screen.findByRole("row", { name: lens.settings.name });
row.focus();
expect(row).toHaveFocus();
await user.keyboard("{Enter}");
expect(await screen.findByRole("heading", { level: 2, name: lens.settings.name })).toBeVisible();
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Back" }));
const editButton = await screen.findByRole("button", { name: `Edit ${lens.settings.name}` });
editButton.focus();
expect(editButton).toHaveFocus();
await user.keyboard("{Enter}");
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByDisplayValue(lens.settings.name)).toBeVisible();
expect(screen.queryByRole("heading", { level: 2, name: lens.settings.name })).not.toBeInTheDocument();
});
it("opens a failed investigation's details from its row and edits only from the pencil", async () => {
window.history.replaceState({}, "", "/lens/");
testQueryClient.clear();
const job = {
...lens.jobs[0],
id: "failed-run",
status: "failed" as const,
stage: "Failed",
error: "boom",
findings: [],
};
vi.mocked(apiClient.get).mockImplementation(async (path) => {
if (path === "/lens") return { lenses: [{ ...lens, jobs: [job] }], workers: [], tracing_enabled: true };
if (path === "/lens/activity/available") return { traces: true, requests: false };
if (path === "/lens/lens/runs") return [job];
if (path === "/lens/lens/runs/failed-run") return job;
if (path === "/lens/agents") return [];
return { data: [] };
});
const user = userEvent.setup();
renderWithProviders(<InvestigationsView accessToken="test" />);
await user.click(await screen.findByRole("button", { name: `Edit ${lens.settings.name}` }));
const dialog = await screen.findByRole("dialog");
expect(within(dialog).getByDisplayValue(lens.settings.name)).toBeVisible();
await user.click(within(dialog).getByRole("button", { name: "Cancel" }));
await waitFor(() => expect(screen.queryByRole("dialog")).not.toBeInTheDocument());
await user.click(screen.getByRole("row", { name: lens.settings.name }));
expect(within(await screen.findByRole("alert")).getByLabelText("Investigation error")).toHaveTextContent("boom");
expect(screen.queryByRole("dialog")).not.toBeInTheDocument();
});

View file

@ -1,5 +1,10 @@
"use client";
import { InvestigationMissing, InvestigationsLoading, InvestigationError } from "./InvestigationStatus";
import {
InvestigationMissing,
InvestigationsLoadFailed,
InvestigationsLoading,
InvestigationError,
} from "./InvestigationStatus";
import { InvestigationNavigation } from "./InvestigationNavigation";
import { useInvestigationResults } from "./useInvestigationResults";
@ -175,7 +180,11 @@ export function InvestigationsView({
setEditing={setEditing}
/>
)}
{(error || query.error) && <InvestigationError error={error} queryError={query.error} refresh={refresh} />}
{query.error && !query.data ? (
<InvestigationsLoadFailed queryError={query.error} refresh={refresh} />
) : (
(error || query.error) && <InvestigationError message={error || query.error?.message || ""} refresh={refresh} />
)}
{query.isLoading && <InvestigationsLoading />}
{loaded && showEmpty && (
<InvestigationsWelcome
@ -228,6 +237,7 @@ export function InvestigationsView({
lenses={lenses}
connected={connected}
readOnly={readOnly}
onOpen={selectLens}
onEdit={(id) => openDialog("edit", id)}
onRunNow={(id) => openDialog("run_now", id)}
onOpenFinding={openFinding}

View file

@ -40,7 +40,7 @@ export function InvestigationsWelcome({
className: "col-start-2 w-fit sm:col-start-auto",
});
return (
<section aria-labelledby="lens-welcome" className="max-w-3xl pb-6">
<section aria-labelledby="lens-welcome" className="m-auto w-full max-w-2xl py-10">
{showPreview && <LensPreviewButton />}
<h2 id="lens-welcome" className="text-xl font-semibold tracking-tight">
Find what needs attention

View file

@ -11,8 +11,8 @@ import type { Job } from "../model/types";
const STEP_MARK = { model: "✓", stage: "▸", error: "!" } as const;
const STEP_TONE = {
model: "text-foreground",
stage: "text-[#0011b3] dark:text-[#8b9bff]",
error: "text-[#e5484d]",
stage: "text-info",
error: "text-destructive",
} as const;
export function StepFeed({ job }: { job: Job }) {
@ -21,7 +21,7 @@ export function StepFeed({ job }: { job: Job }) {
const models = modelsUsed(steps);
const calls = steps.filter((s) => s.kind === "model").length;
return (
<section aria-label="Live investigation steps" className="rounded-md border bg-background font-mono text-[11px]">
<section aria-label="Live investigation steps" className="rounded-md border bg-background font-mono text-xs">
<button
type="button"
onClick={() => setOpen(!open)}

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

View file

@ -0,0 +1,140 @@
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";
import { describe, expect, it, vi } from "vitest";
import { runs } from "./__fixtures__/runs";
import { RunSearch } from "./RunSearch";
function Harness({ initial = "" }: { initial?: string }) {
const [value, setValue] = useState(initial);
return (
<>
<RunSearch value={value} onChange={setValue} runs={runs} />
<button type="button" onClick={() => setValue("status:ok")}>
Load saved query
</button>
</>
);
}
/** Delivers each emitted value only when asked, like a throttled URL. */
function LaggingHarness() {
const [value, setValue] = useState("");
const [queue, setQueue] = useState<string[]>([]);
return (
<>
<RunSearch value={value} onChange={(next) => setQueue((q) => [...q, next])} runs={runs} />
<output aria-label="undelivered">{queue.join("|")}</output>
<button
type="button"
onClick={() => {
setValue(queue[0]);
setQueue(queue.slice(1));
}}
>
Deliver next
</button>
</>
);
}
const box = () => screen.getByRole("combobox", { name: "Search runs" });
const undelivered = () => screen.getByRole("status", { name: "undelivered" });
const listbox = () => screen.getByRole("listbox", { name: "Search suggestions" });
describe("RunSearch", () => {
it("builds a filter from the keyboard: field, then value", async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(box());
expect(box()).toHaveAttribute("aria-expanded", "true");
await user.keyboard("-sta");
expect(
within(listbox())
.getAllByRole("option")
.map((o) => o.textContent),
).toEqual(["status"]);
await user.keyboard("{Enter}");
expect(box()).toHaveTextContent(/^-status:$/, { normalizeWhitespace: false });
expect(within(listbox()).getByRole("group", { name: "status" })).toBeVisible();
expect(within(listbox()).getByText("does not contain")).toBeVisible();
await user.keyboard("{ArrowDown}");
const ok = within(listbox()).getByRole("option", { name: "ok" });
expect(ok).toHaveAttribute("aria-selected", "true");
expect(box()).toHaveAttribute("aria-activedescendant", ok.id);
await user.keyboard("{Enter}");
expect(box()).toHaveTextContent(/^-status:ok $/, { normalizeWhitespace: false });
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
expect(box()).toHaveAttribute("aria-expanded", "false");
});
it("wraps arrow navigation and picks with Tab", async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(box());
await user.keyboard("{ArrowUp}");
expect(within(listbox()).getByRole("option", { name: "trace_id" })).toHaveAttribute("aria-selected", "true");
await user.keyboard("{ArrowDown}{ArrowDown}{Tab}");
expect(box()).toHaveTextContent(/^agent:$/, { normalizeWhitespace: false });
await user.keyboard("{ArrowDown}{ArrowDown}e");
expect(within(listbox()).getByRole("option", { name: "billing-agent" })).toHaveAttribute("aria-selected", "true");
});
it("picks a suggestion with the mouse without losing focus", async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(box());
await user.keyboard("agent:");
await user.click(within(listbox()).getByRole("option", { name: "researcher" }));
expect(box()).toHaveTextContent(/^agent:researcher $/, { normalizeWhitespace: false });
expect(box()).toHaveFocus();
await user.keyboard("refund");
expect(box()).toHaveTextContent(/^agent:researcher refund$/, { normalizeWhitespace: false });
});
it("stays one line: Enter with no menu adds nothing, and Escape closes the menu until ArrowDown", async () => {
const user = userEvent.setup();
render(<Harness />);
await user.click(box());
await user.keyboard("refund{Enter}");
expect(box()).toHaveTextContent(/^refund$/, { normalizeWhitespace: false });
await user.keyboard(" {Escape}");
expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
await user.keyboard("{ArrowDown}");
expect(listbox()).toBeVisible();
});
it("emits the query once typing pauses, not per keystroke", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
render(<RunSearch value="" onChange={onChange} runs={runs} />);
await user.click(box());
await user.keyboard("refund");
await waitFor(() => expect(onChange).toHaveBeenCalledWith("refund"));
expect(onChange).toHaveBeenCalledOnce();
});
it("keeps typing intact while the value it emitted arrives late", async () => {
const user = userEvent.setup();
render(<LaggingHarness />);
await user.click(box());
await user.keyboard("ab");
await waitFor(() => expect(undelivered()).toHaveTextContent(/^ab$/, { normalizeWhitespace: false }));
await user.keyboard("c");
await user.click(screen.getByRole("button", { name: "Deliver next" }));
await user.click(box());
await user.keyboard("d");
expect(box()).toHaveTextContent(/^abcd$/, { normalizeWhitespace: false });
await waitFor(() => expect(undelivered()).toHaveTextContent(/^abcd$/, { normalizeWhitespace: false }));
});
it("shows a query set from outside, such as the URL", async () => {
const user = userEvent.setup();
render(<Harness initial="agent:triage" />);
expect(box()).toHaveTextContent("agent:triage");
await user.click(screen.getByRole("button", { name: "Load saved query" }));
expect(box()).toHaveTextContent(/^status:ok$/, { normalizeWhitespace: false });
});
});

View file

@ -0,0 +1,257 @@
"use client";
import "prosemirror-view/style/prosemirror.css";
import { useDebouncedCallback } from "@tanstack/react-pacer/debouncer";
import { ProseMirror, ProseMirrorDoc, reactKeys, useEditorEventCallback } from "@handlewithcare/react-prosemirror";
import {
Bot,
Box,
Braces,
CircleDashed,
CornerDownLeft,
Hash,
type LucideIcon,
Search,
SquareChevronRight,
} from "lucide-react";
import { Schema } from "prosemirror-model";
import { EditorState, TextSelection, type Transaction } from "prosemirror-state";
import { Decoration, DecorationSet, type EditorView } from "prosemirror-view";
import { useId, useMemo, useState } from "react";
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
import { parseRunQuery, type RunField } from "./runQuery";
import { suggest, type Suggestion, type SuggestionMenu } from "./suggestions";
const schema = new Schema({ nodes: { doc: { content: "text*" }, text: {} } });
const createState = (text: string) =>
EditorState.create({ schema, doc: schema.node("doc", null, text ? schema.text(text) : []), plugins: [reactKeys()] });
const EMIT_WAIT_MS = 150;
const KEEP_MENU_CLOSED = "runSearchKeepMenuClosed";
const FIELD_ICONS: Record<RunField, LucideIcon> = {
name: SquareChevronRight,
agent: Bot,
status: CircleDashed,
model: Box,
input: Braces,
trace_id: Hash,
};
const OPERATORS = [
{ label: "equals", example: "foo:bar" },
{ label: "not equals", example: "-foo:bar" },
{ label: "wildcard match", example: "foo:*bar*" },
{ label: "does not contain", example: "-foo:*bar*" },
];
function highlight(state: EditorState): DecorationSet {
const marks = parseRunQuery(state.doc.textContent).flatMap((clause) =>
clause.kind === "field"
? [
Decoration.inline(clause.from, clause.keyTo, { class: "text-info" }),
Decoration.inline(clause.keyTo, clause.keyTo + 1, { class: "text-muted-foreground" }),
]
: [],
);
return DecorationSet.create(state.doc, marks);
}
function applySuggestion(view: EditorView, item: Suggestion) {
const tr = view.state.tr.insertText(item.insert, item.from, item.to);
tr.setSelection(TextSelection.create(tr.doc, item.from + item.insert.length));
if (item.completesClause) tr.setMeta(KEEP_MENU_CLOSED, true);
view.dispatch(tr);
}
interface RunSearchProps {
value: string;
onChange: (value: string) => void;
/** Loaded runs, the source of value suggestions. */
runs: readonly TraceSummary[];
}
/** One query box for the runs list: free text plus `key:value` filters, with autocomplete for both. */
export function RunSearch({ value, onChange, runs }: RunSearchProps) {
const listId = useId();
const [state, setState] = useState(() => createState(value));
const [seenValue, setSeenValue] = useState(value);
const [unechoed, setUnechoed] = useState<readonly string[]>([]);
const [focused, setFocused] = useState(false);
const [menuOpen, setMenuOpen] = useState(true);
const [active, setActive] = useState(0);
// `value` can trail the editor (the URL updates are throttled), so an echo of text we emitted is not an edit.
if (value !== seenValue) {
setSeenValue(value);
const echo = unechoed.indexOf(value);
if (echo >= 0) setUnechoed(unechoed.slice(echo + 1));
else if (value !== state.doc.textContent) {
setUnechoed([]);
setState(createState(value));
}
}
const { selection } = state;
const text = state.doc.textContent;
const menu = useMemo(() => (selection.empty ? suggest(text, selection.head, runs) : null), [text, selection, runs]);
const items = menu?.groups.flatMap((group) => group.items) ?? [];
const shownMenu = focused && menuOpen ? menu : null;
const visible = shownMenu !== null;
const activeItem = visible ? items[Math.min(active, items.length - 1)] : undefined;
const emit = useDebouncedCallback(
(text: string) => {
setUnechoed((current) => [...current, text]);
onChange(text);
},
{ wait: EMIT_WAIT_MS },
);
const dispatchTransaction = (tr: Transaction) => {
setState((current) => current.apply(tr));
if (tr.selectionSet || tr.docChanged) setActive(0);
if (!tr.docChanged) return;
setMenuOpen(!tr.getMeta(KEEP_MENU_CLOSED));
emit(tr.doc.textContent);
};
const handleKeyDown = (view: EditorView, event: KeyboardEvent): boolean => {
if (event.key === "Escape" && visible) {
setMenuOpen(false);
return true;
}
if (event.key === "ArrowDown" && !visible) {
setMenuOpen(true);
return true;
}
if ((event.key === "ArrowDown" || event.key === "ArrowUp") && items.length > 0) {
const step = event.key === "ArrowDown" ? 1 : -1;
setActive((Math.min(active, items.length - 1) + step + items.length) % items.length);
return true;
}
if ((event.key === "Enter" || event.key === "Tab") && activeItem) {
applySuggestion(view, activeItem);
return true;
}
return false;
};
return (
<ProseMirror
state={state}
dispatchTransaction={dispatchTransaction}
decorations={highlight}
handleKeyDown={handleKeyDown}
handleDOMEvents={{
focus: () => {
setFocused(true);
setMenuOpen(true);
return false;
},
blur: () => {
setFocused(false);
return false;
},
}}
transformPastedText={(pasted) => pasted.replace(/\s+/g, " ")}
attributes={{
role: "combobox",
"aria-label": "Search runs",
"aria-autocomplete": "list",
"aria-expanded": String(visible),
"aria-controls": listId,
...(activeItem && { "aria-activedescendant": `${listId}-${activeItem.id}` }),
spellcheck: "false",
class: "min-w-0 flex-1 overflow-x-auto py-1.5 font-mono text-xs whitespace-pre outline-none",
}}
>
<div className="relative w-full flex-1">
<div className="flex h-8 items-center gap-2 rounded-md border border-input bg-transparent px-2.5 focus-within:border-ring focus-within:ring-[3px] focus-within:ring-ring/50 dark:bg-input/30">
<Search className="size-3.5 shrink-0 text-muted-foreground" />
<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">
Search runs, or filter like agent:researcher status:error
</span>
)}
</div>
</div>
{shownMenu && <SuggestionPanel id={listId} menu={shownMenu} activeId={activeItem?.id} />}
</div>
</ProseMirror>
);
}
function SuggestionPanel({ id, menu, activeId }: { id: string; menu: SuggestionMenu; activeId: string | undefined }) {
const pick = useEditorEventCallback((view, item: Suggestion) => applySuggestion(view, item));
return (
<div className="absolute top-full left-0 z-floating mt-1 w-full max-w-xl overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-md">
<div id={id} role="listbox" aria-label="Search suggestions" className="max-h-80 overflow-y-auto p-1">
{menu.groups.map((group) => (
<div key={group.heading} role="group" aria-label={group.heading}>
<div className="px-2 pt-2 pb-1 text-xs text-muted-foreground">{group.heading}</div>
{group.items.map((item) => {
const Icon = FIELD_ICONS[item.field];
return (
<div
key={item.id}
id={`${id}-${item.id}`}
role="option"
aria-selected={item.id === activeId}
onMouseDown={(event) => event.preventDefault()}
onClick={() => pick(item)}
className="flex cursor-pointer items-center gap-2 rounded-sm px-2 py-1.5 font-mono text-xs aria-selected:bg-accent aria-selected:text-accent-foreground"
>
<Icon className="size-3.5 shrink-0 text-muted-foreground" />
<span className="truncate">{item.label}</span>
</div>
);
})}
</div>
))}
{menu.showOperators && <OperatorHints />}
</div>
<div className="flex items-center gap-3 border-t border-border px-3 py-2 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Kbd>↑</Kbd>
<Kbd>↓</Kbd>
Navigate
</span>
<span className="flex items-center gap-1">
<Kbd>
<CornerDownLeft className="size-3" />
</Kbd>
Select
</span>
</div>
</div>
);
}
function OperatorHints() {
return (
<div className="pb-1">
<div className="px-2 pt-2 pb-1 text-xs text-muted-foreground">Comparison operators</div>
{OPERATORS.map((op) => (
<div key={op.label} className="flex items-center justify-between px-2 py-1 font-mono text-xs">
<span className="text-warning">{op.label}</span>
<code className="rounded bg-muted px-1.5 text-muted-foreground">{op.example}</code>
</div>
))}
</div>
);
}
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="inline-flex h-5 min-w-5 items-center justify-center rounded border border-border px-1 font-sans">
{children}
</kbd>
);
}

View file

@ -0,0 +1,22 @@
"use client";
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
import { RunSearch } from "./RunSearch";
interface RunsToolbarProps {
query: string;
onQueryChange: (value: string) => void;
runs: readonly TraceSummary[];
/** Extra controls (time range, live tail) rendered on the right. */
children?: React.ReactNode;
}
export function RunsToolbar({ query, onQueryChange, runs, children }: RunsToolbarProps) {
return (
<div className="flex min-h-11 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-card px-3 py-2">
<RunSearch value={query} onChange={onQueryChange} runs={runs} />
{children && <div className="ml-auto flex items-center gap-2">{children}</div>}
</div>
);
}

View file

@ -0,0 +1,42 @@
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
export const run = (overrides: Partial<TraceSummary>): TraceSummary => ({
agent_count: 1,
agent_invocations: 1,
duration_ms: 10,
error_count: 0,
input_preview: "",
input_tokens: 0,
llm_calls: 1,
models: [],
name: "run",
output_tokens: 0,
service: "svc",
span_count: 1,
spend: null,
start_time: "2026-10-01T00:00:00Z",
status: "ok",
tool_calls: 0,
trace_id: "trace",
...overrides,
});
const refundOverrides = {
trace_id: "aaa111",
name: "support",
input_preview: "Where is my refund?",
agent_names: ["billing-agent", "triage"],
models: ["gpt-5"],
};
const refund = run(refundOverrides);
const researchOverrides = {
trace_id: "bbb222",
name: "research_lead",
input_preview: "Compare vector stores",
agent_names: ["researcher"],
models: ["claude-opus"],
error_count: 2,
};
const research = run(researchOverrides);
const plain = run({ trace_id: "ccc333", name: "health", service: "cron" });
export const runs = [refund, research, plain];

View file

@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { run, runs } from "./__fixtures__/runs";
import { fieldValues, filterRuns } from "./runQuery";
const ids = (query: string) => filterRuns(runs, query).map((r) => r.trace_id);
describe("filterRuns", () => {
it("returns every run for an empty or blank query", () => {
expect(filterRuns(runs, " ")).toBe(runs);
});
it("matches free text against input, name and trace id, ignoring case, with every term required", () => {
expect(ids("REFUND")).toEqual(["aaa111"]);
expect(ids("lead")).toEqual(["bbb222"]);
expect(ids("ccc3")).toEqual(["ccc333"]);
expect(ids("vector research")).toEqual(["bbb222"]);
expect(ids("vector refund")).toEqual([]);
});
it("keeps a quoted phrase together", () => {
expect(ids('"my refund"')).toEqual(["aaa111"]);
expect(ids('"refund my"')).toEqual([]);
});
it("splits runs by status, judged by recorded errors", () => {
expect(ids("status:error")).toEqual(["bbb222"]);
expect(ids("-status:error")).toEqual(["aaa111", "ccc333"]);
expect(ids("status:OK")).toEqual(["aaa111", "ccc333"]);
});
it("matches a multi-valued field when any value matches, and negation when none does", () => {
expect(ids("agent:triage")).toEqual(["aaa111"]);
expect(ids("-agent:triage")).toEqual(["bbb222", "ccc333"]);
expect(ids("agent:cron")).toEqual(["ccc333"]);
expect(ids("model:gpt-5")).toEqual(["aaa111"]);
});
it("requires a whole-value match unless the value has wildcards", () => {
expect(ids("agent:billing")).toEqual([]);
expect(ids("agent:*bill*")).toEqual(["aaa111"]);
expect(ids("agent:billing*")).toEqual(["aaa111"]);
expect(ids("agent:*agent")).toEqual(["aaa111"]);
expect(ids("-agent:*search*")).toEqual(["aaa111", "ccc333"]);
expect(ids("name:res.arch_lead")).toEqual([]);
});
it("matches glob segments case-insensitively without overlapping exact anchors", () => {
const billing = run({ trace_id: "billing", agent_names: ["BILLING-agent"] });
const research = run({ trace_id: "research", agent_names: ["research-billing-agent"] });
const doubled = run({ trace_id: "doubled", agent_names: ["agent"] });
const globRuns = [billing, research, doubled];
expect(filterRuns(globRuns, "agent:*bill*")).toEqual([billing, research]);
expect(filterRuns(globRuns, "agent:billing*")).toEqual([billing]);
expect(filterRuns(globRuns, "agent:*agent")).toEqual(globRuns);
expect(filterRuns([run({ name: "aa" })], "name:a*a")).toHaveLength(1);
expect(filterRuns([run({ name: "a" })], "name:a*a")).toHaveLength(0);
expect(filterRuns(globRuns, "agent:**")).toEqual(filterRuns(globRuns, "agent:*"));
});
it("matches adversarial globs without catastrophic backtracking", () => {
const longValue = run({ agent_names: ["a".repeat(5000)] });
const pattern = `agent:${"a*".repeat(30)}b`;
const startedAt = performance.now();
const matches = filterRuns([longValue], pattern);
const elapsed = performance.now() - startedAt;
expect(matches).toEqual([]);
expect(elapsed).toBeLessThan(100);
});
it("searches the readable input text and quoted values with spaces", () => {
expect(ids('input:"*vector stores"')).toEqual(["bbb222"]);
expect(ids("trace_id:bbb222")).toEqual(["bbb222"]);
});
it("combines field clauses with free text", () => {
expect(ids("agent:*e* -status:error refund")).toEqual(["aaa111"]);
});
it("ignores a key still waiting for its value and treats unknown keys as text", () => {
expect(ids("agent:")).toEqual(["aaa111", "bbb222", "ccc333"]);
expect(ids("refund agent:")).toEqual(["aaa111"]);
expect(ids("foo:bar")).toEqual([]);
expect(filterRuns([run({ name: "foo:bar" })], "foo:bar")).toHaveLength(1);
});
});
describe("fieldValues", () => {
it("lists each distinct non-empty value once, sorted", () => {
expect(fieldValues(runs, "agent")).toEqual(["billing-agent", "cron", "researcher", "triage"]);
expect(fieldValues(runs, "status")).toEqual(["error", "ok"]);
expect(fieldValues([run({ models: [] })], "model")).toEqual([]);
});
});

View file

@ -0,0 +1,111 @@
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
import { previewText, traceAgentNames } from "@/components/view_logs/TraceView/traceUtils";
type RunFieldGroup = "Run attributes" | "Content" | "Identity";
interface RunFieldSpec {
group: RunFieldGroup;
read: (run: TraceSummary) => readonly string[];
suggestValues: boolean;
}
const runStatus = (run: TraceSummary): string => (run.error_count > 0 ? "error" : "ok");
export const RUN_FIELDS = {
name: { group: "Run attributes", read: (run) => [run.name], suggestValues: true },
agent: { group: "Run attributes", read: traceAgentNames, suggestValues: true },
status: { group: "Run attributes", read: (run) => [runStatus(run)], suggestValues: true },
model: { group: "Run attributes", read: (run) => run.models, suggestValues: true },
input: { group: "Content", read: (run) => [previewText(run.input_preview)], suggestValues: false },
trace_id: { group: "Identity", read: (run) => [run.trace_id], suggestValues: false },
} as const satisfies Record<string, RunFieldSpec>;
export type RunField = keyof typeof RUN_FIELDS;
const isRunField = (key: string): key is RunField => Object.hasOwn(RUN_FIELDS, key);
interface Span {
from: number;
to: number;
}
export type QueryClause =
| (Span & { kind: "text"; value: string })
| (Span & { kind: "field"; field: RunField; negated: boolean; keyTo: number; value: string });
const unquote = (raw: string): string => raw.replace(/^"([^"]*)"?$/, "$1");
/** Whitespace-separated tokens; a double-quoted stretch keeps its spaces. */
function tokenize(text: string): (Span & { raw: string })[] {
return Array.from(text.matchAll(/(?:"[^"]*"?|\S)+/g), (match) => ({
raw: match[0],
from: match.index,
to: match.index + match[0].length,
}));
}
function parseToken({ raw, from, to }: Span & { raw: string }): QueryClause {
const match = /^(-?)([A-Za-z_]+):(.*)$/.exec(raw);
const key = match?.[2].toLowerCase() ?? "";
if (!match || !isRunField(key)) return { kind: "text", value: unquote(raw), from, to };
const negated = match[1] === "-";
return {
kind: "field",
field: key,
negated,
keyTo: from + match[1].length + match[2].length,
value: unquote(match[3]),
from,
to,
};
}
export const parseRunQuery = (text: string): QueryClause[] => tokenize(text).map(parseToken);
/** `bar` matches the whole value, `*bar*` is a glob; both ignore case. */
function valueMatcher(pattern: string): (value: string) => boolean {
const needle = pattern.toLowerCase();
if (!needle.includes("*")) return (value) => value.toLowerCase() === needle;
const segments = needle.split("*");
const first = segments[0];
const last = segments.at(-1);
const middle = segments.slice(1, -1).filter(Boolean);
return (value) => {
const candidate = value.toLowerCase();
if (first && !candidate.startsWith(first)) return false;
if (last && !candidate.endsWith(last)) return false;
const suffixStart = last ? candidate.length - last.length : candidate.length;
let cursor = first ? first.length : 0;
if (suffixStart < cursor) return false;
for (const segment of middle) {
const found = candidate.indexOf(segment, cursor);
if (found < 0 || found + segment.length > suffixStart) return false;
cursor = found + segment.length;
}
return true;
};
}
function matchesClause(run: TraceSummary, clause: QueryClause): boolean {
if (clause.kind === "text") {
const needle = clause.value.toLowerCase();
return [run.trace_id, previewText(run.input_preview), run.name].some((text) => text.toLowerCase().includes(needle));
}
// A key typed without a value yet narrows nothing, so the list does not blank out mid-typing.
if (!clause.value) return true;
const matches = RUN_FIELDS[clause.field].read(run).some(valueMatcher(clause.value));
return clause.negated ? !matches : matches;
}
/** Every clause must hold: free text searches input, name and trace id; `key:value` clauses target one field. */
export function filterRuns(runs: TraceSummary[], query: string): TraceSummary[] {
const clauses = parseRunQuery(query);
if (clauses.length === 0) return runs;
return runs.filter((run) => clauses.every((clause) => matchesClause(run, clause)));
}
export function fieldValues(runs: readonly TraceSummary[], field: RunField): string[] {
return Array.from(new Set(runs.flatMap((run) => RUN_FIELDS[field].read(run))))
.filter(Boolean)
.sort();
}

View file

@ -0,0 +1,75 @@
import { describe, expect, it } from "vitest";
import { run, runs } from "./__fixtures__/runs";
import { suggest, type Suggestion, type SuggestionMenu } from "./suggestions";
const atEnd = (text: string, source = runs) => suggest(text, text.length, source);
const items = (menu: SuggestionMenu | null): Suggestion[] => menu?.groups.flatMap((g) => g.items) ?? [];
const labels = (menu: SuggestionMenu | null) => items(menu).map((item) => item.label);
const apply = (text: string, item: Suggestion) => text.slice(0, item.from) + item.insert + text.slice(item.to);
describe("suggest", () => {
it("offers every field, grouped, in an empty box", () => {
const menu = atEnd("");
expect(menu?.groups.map((g) => g.heading)).toEqual(["Run attributes", "Content", "Identity"]);
expect(labels(menu)).toEqual(["name", "agent", "status", "model", "input", "trace_id"]);
expect(menu?.showOperators).toBe(false);
});
it("narrows fields by the typed prefix and completes the key with a colon", () => {
const menu = atEnd("refund AG");
expect(labels(menu)).toEqual(["agent"]);
expect(apply("refund AG", items(menu)[0])).toBe("refund agent:");
expect(items(menu)[0].completesClause).toBe(false);
});
it("keeps a leading minus when completing a negated key", () => {
expect(apply("-st", items(atEnd("-st"))[0])).toBe("-status:");
});
it("offers nothing for free text that names no field", () => {
expect(atEnd("refund")).toBeNull();
expect(atEnd("foo:bar")).toBeNull();
});
it("offers the loaded values and the operator help once a key has its colon", () => {
const menu = atEnd("agent:");
expect(menu?.groups.map((g) => g.heading)).toEqual(["agent"]);
expect(labels(menu)).toEqual(["billing-agent", "cron", "researcher", "triage"]);
expect(menu?.showOperators).toBe(true);
});
it("narrows values by substring, ignoring wildcards, and drops operator help once a value is typed", () => {
const menu = atEnd("-agent:*AR*");
expect(labels(menu)).toEqual(["researcher"]);
expect(menu?.showOperators).toBe(false);
expect(atEnd("agent:zzz")).toBeNull();
});
it("replaces only the value, adds a separating space at the end, and finishes the clause", () => {
const item = items(atEnd("-agent:bil"))[0];
expect(apply("-agent:bil", item)).toBe("-agent:billing-agent ");
expect(item.completesClause).toBe(true);
const text = "agent:tri refund";
expect(apply(text, items(suggest(text, 9, runs))[0])).toBe("agent:triage refund");
});
it("quotes a value that contains spaces", () => {
const spaced = [run({ name: "Order lookup" })];
expect(apply("name:", items(atEnd("name:", spaced))[0])).toBe('name:"Order lookup" ');
});
it("does not list values for free-form fields but still explains the operators", () => {
const menu = atEnd("input:");
expect(menu?.groups).toEqual([]);
expect(menu?.showOperators).toBe(true);
expect(atEnd("input:x")).toBeNull();
});
it("offers fields in the gap between tokens, but nothing mid-token", () => {
const text = "refund status:ok";
expect(apply(text, items(suggest(text, 7, runs))[1])).toBe("refund agent: status:ok");
expect(suggest(text, 3, runs)).toBeNull();
expect(suggest("status:ok", 0, runs)).toBeNull();
});
});

View file

@ -0,0 +1,91 @@
import type { TraceSummary } from "@/components/view_logs/TraceView/traceTypes";
import { fieldValues, parseRunQuery, RUN_FIELDS, type QueryClause, type RunField } from "./runQuery";
export interface Suggestion {
id: string;
label: string;
field: RunField;
/** Replaces `text.slice(from, to)`. */
from: number;
to: number;
insert: string;
/** A value finishes the clause and closes the menu; a field keeps it open for its values. */
completesClause: boolean;
}
export interface SuggestionGroup {
heading: string;
items: Suggestion[];
}
export interface SuggestionMenu {
groups: SuggestionGroup[];
showOperators: boolean;
}
const MAX_VALUES = 50;
const FIELDS = Object.keys(RUN_FIELDS) as RunField[];
const isSpace = (char: string | undefined) => char === undefined || /\s/.test(char);
const quoteIfNeeded = (value: string) => (/[\s"]/.test(value) ? `"${value.replaceAll('"', "")}"` : value);
function fieldMenu(prefix: string, negated: boolean, from: number, to: number): SuggestionMenu | null {
const fields = FIELDS.filter((field) => field.startsWith(prefix.toLowerCase()));
if (fields.length === 0) return null;
const groups = Array.from(new Set(fields.map((field) => RUN_FIELDS[field].group)), (heading) => ({
heading,
items: fields
.filter((field) => RUN_FIELDS[field].group === heading)
.map((field) => ({
id: `field:${field}`,
label: field,
field,
from,
to,
insert: `${negated ? "-" : ""}${field}:`,
completesClause: false,
})),
}));
return { groups, showOperators: false };
}
function valueMenu(
clause: Extract<QueryClause, { kind: "field" }>,
text: string,
runs: readonly TraceSummary[],
): SuggestionMenu | null {
const from = clause.keyTo + 1;
const needle = clause.value.replaceAll("*", "").toLowerCase();
const trailing = clause.to === text.length ? " " : "";
const values = RUN_FIELDS[clause.field].suggestValues ? fieldValues(runs, clause.field) : [];
const items = values
.filter((value) => value.toLowerCase().includes(needle))
.slice(0, MAX_VALUES)
.map((value) => ({
id: `value:${value}`,
label: value,
field: clause.field,
from,
to: clause.to,
insert: quoteIfNeeded(value) + trailing,
completesClause: true,
}));
const showOperators = clause.value === "";
if (items.length === 0 && !showOperators) return null;
return { groups: items.length ? [{ heading: clause.field, items }] : [], showOperators };
}
/** What to offer at the cursor: fields while a key is being typed, values once it has a colon. */
export function suggest(text: string, cursor: number, runs: readonly TraceSummary[]): SuggestionMenu | null {
const clause = parseRunQuery(text).find((c) => c.from < cursor && c.to === cursor);
if (!clause) {
const betweenTokens = isSpace(text[cursor - 1]) && isSpace(text[cursor]);
return betweenTokens ? fieldMenu("", false, cursor, cursor) : null;
}
if (clause.kind === "field") return valueMenu(clause, text, runs);
const raw = text.slice(clause.from, clause.to);
const negated = raw.startsWith("-");
const prefix = negated ? raw.slice(1) : raw;
if (/[:"]/.test(prefix)) return null;
return fieldMenu(prefix, negated, clause.from, clause.to);
}

View file

@ -0,0 +1,239 @@
"use client";
import { ArrowLeft, ChevronDown, ChevronsRight, ChevronUp, Maximize2, Minimize2, X } from "lucide-react";
import { useCallback, useRef, useState, type ReactNode, type RefObject } from "react";
import { useEventListener, useLocalStorage, useMediaQuery, useOnClickOutside, useWindowSize } from "usehooks-ts";
import { cn } from "@/lib/cva.config";
import { ignoresLetterShortcut } from "@/components/view_logs/letterShortcut";
const WIDTH_KEY = "litellm.agentTraces.drawerWidth";
const MIN_WIDTH = 700;
const MIN_LEFT_GAP = 100;
const DEFAULT_FRACTION = 0.56;
const KEY_STEP = 32;
export const clampDrawerWidth = (width: number, viewport: number): number => {
const max = Math.max(Math.min(MIN_WIDTH, viewport), viewport - MIN_LEFT_GAP);
return Math.round(Math.min(Math.max(width, Math.min(MIN_WIDTH, max)), max));
};
export const PANEL_TRIGGER = { "data-side-panel-trigger": "" } as const;
const KEEPS_PANEL_OPEN =
"[data-side-panel-trigger], [role='dialog'], [role='menu'], [role='listbox'], [data-radix-popper-content-wrapper]";
const OWNS_ITS_KEYS = "[role='dialog'], [role='menu'], [role='listbox']";
const insideKeyOwner = (event: KeyboardEvent): boolean =>
event.target instanceof Element && event.target.closest(OWNS_ITS_KEYS) !== null;
function useDrawerWidth() {
const { width: viewport } = useWindowSize();
const [stored, setStored] = useLocalStorage<number | null>(WIDTH_KEY, null, {
serializer: String,
deserializer: (raw) => (Number.isFinite(Number(raw)) ? Number(raw) : null),
});
const width = clampDrawerWidth(stored ?? viewport * DEFAULT_FRACTION, viewport);
const update = useCallback((next: number) => setStored(clampDrawerWidth(next, window.innerWidth)), [setStored]);
return [width, update] as const;
}
function useExitPresence<T>(item: T | null, itemKey: (item: T) => string) {
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
const [lastShown, setLastShown] = useState<T | null>(item);
const [exitedKey, setExitedKey] = useState<string | null>(null);
if (item !== null && item !== lastShown) setLastShown(item);
if (item !== null && exitedKey !== null) setExitedKey(null);
const shown = item ?? lastShown;
const closing = item === null && shown !== null;
const exited = closing && (reduceMotion || exitedKey === itemKey(shown));
const onExited = () => {
if (closing) setExitedKey(itemKey(shown));
};
return { shown: exited ? null : shown, closing, onExited };
}
function ResizeHandle({ noun, width, onResize }: { noun: string; width: number; onResize: (width: number) => void }) {
const [dragging, setDragging] = useState(false);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (dragging) onResize(window.innerWidth - event.clientX);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") onResize(width + KEY_STEP);
else if (event.key === "ArrowRight") onResize(width - KEY_STEP);
else return;
event.preventDefault();
event.stopPropagation();
};
return (
<div
role="separator"
aria-orientation="vertical"
aria-label={`Resize ${noun} panel`}
aria-valuenow={width}
aria-valuemin={MIN_WIDTH}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={() => setDragging(false)}
onPointerCancel={() => setDragging(false)}
onKeyDown={onKeyDown}
className="group/handle absolute inset-y-0 -left-2.5 z-raised flex w-5 cursor-col-resize justify-center outline-none"
>
<span
className={cn(
"h-full w-[0.67px] bg-border transition-[width,background-color] duration-150 group-hover/handle:w-0.5 group-focus-visible/handle:w-0.5 group-focus-visible/handle:bg-trace-brand motion-reduce:transition-none",
dragging && "w-0.5 bg-trace-brand",
)}
/>
</div>
);
}
function HeaderButton({
label,
disabled = false,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className="grid size-7 place-items-center rounded-[4px] text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none"
>
{children}
</button>
);
}
export interface SidePanelProps<T> {
item: T | null;
itemKey: (item: T) => string;
noun: string;
label: string;
testId?: string;
index: number;
total: number;
onStep: (delta: number) => void;
onClose: () => void;
fullScreen: boolean;
onFullScreenChange: (fullScreen: boolean) => void;
children: (item: T) => ReactNode;
}
export function SidePanel<T>({
item,
itemKey,
noun,
label,
testId,
index,
total,
onStep,
onClose,
fullScreen,
onFullScreenChange,
children,
}: SidePanelProps<T>) {
const [width, setWidth] = useDrawerWidth();
const panelRef = useRef<HTMLElement>(null);
const { shown, closing, onExited } = useExitPresence(item, itemKey);
useEventListener("keydown", (event) => {
if (item === null || ignoresLetterShortcut(event) || insideKeyOwner(event)) return;
if (event.key === "Escape") {
event.preventDefault();
onClose();
} else if (event.key === "j") {
event.preventDefault();
onStep(1);
} else if (event.key === "k") {
event.preventDefault();
onStep(-1);
}
});
useOnClickOutside(panelRef as RefObject<HTMLElement>, (event) => {
if (item === null || (event instanceof MouseEvent && event.button !== 0)) return;
if (event.target instanceof Element && event.target.closest(KEEPS_PANEL_OPEN) !== null) return;
onClose();
});
if (shown === null) return null;
return (
<aside
ref={panelRef}
aria-label={label}
data-testid={testId}
style={{ width: fullScreen ? "100%" : width }}
onAnimationEnd={(event) => {
if (event.target === event.currentTarget) onExited();
}}
className={cn(
"fixed inset-y-0 right-0 z-overlay flex origin-right flex-col bg-background shadow-[0_10px_15px_-3px_rgba(16,24,40,0.1),0_4px_6px_-4px_rgba(16,24,40,0.1)] motion-reduce:animate-none",
closing ? "animate-trace-drawer-out" : "animate-trace-drawer-in",
)}
>
{!fullScreen && <ResizeHandle noun={noun} width={width} onResize={setWidth} />}
<div className="flex h-[37px] shrink-0 items-center gap-1 border-b border-border px-2">
<HeaderButton label="Close (Esc)" onClick={onClose}>
<ChevronsRight className="size-4" />
</HeaderButton>
<span className="mx-1 h-4 w-px bg-border" />
<HeaderButton label={`Next ${noun} (J)`} disabled={index < 0 || index >= total - 1} onClick={() => onStep(1)}>
<ChevronDown className="size-4" />
</HeaderButton>
<HeaderButton label={`Previous ${noun} (K)`} disabled={index <= 0} onClick={() => onStep(-1)}>
<ChevronUp className="size-4" />
</HeaderButton>
{index >= 0 && (
<span className="ml-1 font-mono text-[11px] text-muted-foreground tabular-nums">
{index + 1} / {total}
</span>
)}
<div className="ml-auto flex items-center gap-1">
<HeaderButton
label={fullScreen ? "Exit full screen" : "Enter full screen"}
onClick={() => onFullScreenChange(!fullScreen)}
>
{fullScreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</HeaderButton>
<HeaderButton label={`Close ${noun} (Esc)`} onClick={onClose}>
<X className="size-4" />
</HeaderButton>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col">{children(shown)}</div>
</aside>
);
}
export function PanelBackLink({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
type="button"
onClick={onClick}
className="-ml-1 inline-flex max-w-full items-center gap-1 rounded px-1 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft aria-hidden="true" className="size-3 shrink-0" />
<span className="truncate">{label}</span>
</button>
);
}

View file

@ -5,11 +5,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { ApiError } from "@/lib/http/client";
import { chooseSelectOption, renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
import { renderWithProviders, testQueryClient } from "../../../../tests/test-utils";
import traceList from "./__fixtures__/trace_list.json";
import { LensPreviewContext } from "@/components/lens/LensPreviewButton";
import AgentTracesPage from "./AgentTracesPage";
import { AgentTracesSection, filterRuns, type TimeControls } from "./AgentTracesSection";
import { filterRuns } from "@/components/lens/runSearch/runQuery";
import { AgentTracesSection, type TimeControls } from "./AgentTracesSection";
import type { TracePage, TraceSummary } from "./traceTypes";
import { RESULTS_CHANGED_MESSAGE } from "./useAgentTraces";
@ -143,7 +144,8 @@ describe("AgentTracesSection", () => {
.mockResolvedValueOnce({ ...(traceList as TracePage), items: runs.slice(1, 2), next_cursor: "later" });
renderSection();
expect(await screen.findByTestId("agent-trace-row")).toBeVisible();
fireEvent.change(screen.getByRole("textbox", { name: "Search runs" }), { target: { value: "no-such-run" } });
await user.type(screen.getByRole("combobox", { name: "Search runs" }), "no-such-run");
await waitFor(() => expect(screen.queryAllByTestId("agent-trace-row")).toHaveLength(0));
act(() => mockAllIsIntersecting(true));
expect(screen.queryByTestId("runs-placeholder")).not.toBeInTheDocument();
expect(agentTraceListCall).toHaveBeenCalledOnce();
@ -204,7 +206,6 @@ describe("AgentTracesSection", () => {
expect(screen.getByText(`Could not load runs: ${message}`)).toBeVisible();
expect(screen.queryByText(/Private token details/)).not.toBeInTheDocument();
expect(agentTraceListCall).toHaveBeenCalledOnce();
expect(screen.getByTestId("runs-footer")).toHaveTextContent("Update failed");
});
it("renders the setup snippet when the proxy answers 501", async () => {
@ -239,10 +240,12 @@ describe("AgentTracesSection", () => {
vi.mocked(agentTraceListCall).mockResolvedValue({ ...(traceList as TracePage), items: [] });
vi.mocked(apiClient.get).mockResolvedValue(traceList);
renderSection();
expect(await screen.findByText("No runs match these filters.")).toBeVisible();
expect(await screen.findByText("No runs in this time range")).toBeVisible();
expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Preview sample" })).not.toBeInTheDocument();
expect(apiClient.get).toHaveBeenCalledWith("/v1/traces", { accessToken: "sk-test", query: { start_ms: 0 } });
fireEvent.click(screen.getByRole("button", { name: "Set up tracing" }));
expect(await screen.findByRole("heading", { name: "Connect another agent" })).toBeVisible();
});
it("checks proxy readiness, waits for an agent, and confirms receipt using actual query results", async () => {
@ -301,7 +304,7 @@ describe("AgentTracesSection", () => {
await act(async () => {
void testQueryClient.invalidateQueries({ queryKey: ["agentTraces"] });
});
expect(await screen.findByText("Updating…")).toBeVisible();
await waitFor(() => expect(screen.getByRole("table", { name: "Agent runs" })).toHaveAttribute("aria-busy", "true"));
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length);
expect(screen.getByRole("complementary", { name: "Trace details" })).toBe(drawer);
expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument();
@ -313,13 +316,13 @@ describe("AgentTracesSection", () => {
vi.mocked(apiClient.get).mockRejectedValue(new ApiError("History unavailable", 503, {}));
renderSection();
expect(await screen.findByRole("alert")).toHaveTextContent("Could not check earlier traces. History unavailable");
expect(screen.getByText("No runs match these filters.")).toBeVisible();
expect(screen.getByText("No runs in this time range")).toBeVisible();
expect(screen.queryByText(/Could not load runs/)).not.toBeInTheDocument();
vi.mocked(apiClient.get).mockResolvedValue(traceList);
fireEvent.click(screen.getByRole("button", { name: "Retry trace check" }));
await waitFor(() => expect(screen.queryByRole("alert")).not.toBeInTheDocument());
expect(screen.getByText("No runs match these filters.")).toBeVisible();
expect(screen.getByText("No runs in this time range")).toBeVisible();
expect(agentTraceListCall).toHaveBeenCalledTimes(1);
});
@ -342,7 +345,6 @@ describe("AgentTracesSection", () => {
expect(lead).toBeDefined();
const failed = rows.find((row) => row.textContent?.includes("acme-404")) as HTMLElement;
expect(within(failed).getByLabelText("2 errors")).toBeInTheDocument();
expect(screen.getByText(`${runs.length} runs`)).toBeInTheDocument();
expect(screen.getByRole("columnheader", { name: "Cost" })).toBeInTheDocument();
expect(within(failed).getByText("—")).toBeInTheDocument();
});
@ -359,22 +361,24 @@ describe("AgentTracesSection", () => {
});
it("filters by input text and by trace id", async () => {
const user = userEvent.setup();
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
renderSection();
await screen.findAllByTestId("agent-trace-row");
const search = screen.getByLabelText("Search runs");
fireEvent.change(search, { target: { value: "acme-404" } });
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1);
const search = screen.getByRole("combobox", { name: "Search runs" });
await user.type(search, "acme-404");
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1));
const lead = runs.find((r) => r.name === "research_lead") as TraceSummary;
fireEvent.change(search, { target: { value: lead.trace_id.slice(0, 10) } });
const rows = screen.getAllByTestId("agent-trace-row");
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveTextContent("Should we store OTEL agent spans");
await user.clear(search);
await user.type(search, lead.trace_id.slice(0, 10));
await waitFor(() =>
expect(screen.getByTestId("agent-trace-row")).toHaveTextContent("Should we store OTEL agent spans"),
);
});
it("uses recorded agent names for the column and filter even when services are shared", async () => {
it("uses recorded agent names for the column, suggestions and filter even when services are shared", async () => {
vi.mocked(agentTraceListCall).mockResolvedValue({
...(traceList as TracePage),
items: [
@ -387,20 +391,19 @@ describe("AgentTracesSection", () => {
await screen.findAllByTestId("agent-trace-row");
expect(screen.getByRole("columnheader", { name: "Agent" })).toBeInTheDocument();
const agentFilter = screen.getByRole("combobox", { name: "Filter by agent" });
expect(agentFilter).toHaveTextContent("All agents");
await user.type(screen.getByRole("combobox", { name: "Search runs" }), "agent:");
const suggestions = screen.getByRole("listbox", { name: "Search suggestions" });
expect(
within(suggestions)
.getAllByRole("option")
.map((option) => option.textContent),
).toEqual(["billing-agent", "research-agent", "review-agent"]);
await chooseSelectOption(user, agentFilter, "billing-agent");
const rows = screen.getAllByTestId("agent-trace-row");
expect(rows).toHaveLength(1);
expect(rows[0]).toHaveTextContent("billing-agent");
expect(rows[0]).not.toHaveTextContent("shared-app");
await chooseSelectOption(user, agentFilter, "review-agent");
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1);
await chooseSelectOption(user, agentFilter, "All agents");
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length);
await user.click(within(suggestions).getByRole("option", { name: "review-agent" }));
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1));
const row = screen.getByTestId("agent-trace-row");
expect(row).toHaveTextContent("billing-agent");
expect(row).not.toHaveTextContent("shared-app");
});
it("shows each run's agent name with the logo of the SDK that produced it", async () => {
@ -528,11 +531,11 @@ describe("AgentTracesSection", () => {
expect(rows[1]).toHaveAttribute("aria-selected", "false");
});
it("reads the search, agent and status filters from the URL and writes changes back", async () => {
it("reads the query from the URL and writes typed changes back", async () => {
const user = userEvent.setup();
vi.mocked(agentTraceListCall).mockResolvedValue(traceList as TracePage);
const onUrlUpdate = vi.fn();
const failed = filterRuns(runs, "", "all", "error");
const failed = filterRuns(runs, "status:error");
renderWithProviders(
<AgentTracesSection
accessToken="sk-test"
@ -542,18 +545,18 @@ describe("AgentTracesSection", () => {
isCustomDate={false}
isLiveTail={false}
/>,
{ searchParams: "?status=error", onUrlUpdate },
{ searchParams: "?q=status:error", onUrlUpdate },
);
expect(await screen.findAllByTestId("agent-trace-row")).toHaveLength(failed.length);
expect(screen.getByRole("combobox", { name: "Filter by status" })).toHaveTextContent("Failed");
expect(failed.length).toBeLessThan(runs.length);
const search = screen.getByRole("combobox", { name: "Search runs" });
expect(search).toHaveTextContent("status:error");
fireEvent.change(screen.getByRole("textbox", { name: "Search runs" }), { target: { value: failed[0].trace_id } });
expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(1);
await waitFor(() => expect(lastUrl(onUrlUpdate).get("q")).toBe(failed[0].trace_id));
expect(lastUrl(onUrlUpdate).get("status")).toBe("error");
await chooseSelectOption(user, screen.getByRole("combobox", { name: "Filter by status" }), "All status");
await waitFor(() => expect(lastUrl(onUrlUpdate).has("status")).toBe(false));
await user.clear(search);
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length));
await user.type(search, "-status:error");
await waitFor(() => expect(screen.getAllByTestId("agent-trace-row")).toHaveLength(runs.length - failed.length));
await waitFor(() => expect(lastUrl(onUrlUpdate).get("q")).toBe("-status:error"));
});
it("plots every loaded run on the timeline", async () => {
@ -636,13 +639,13 @@ describe("AgentTracesPage", () => {
const trigger = screen.getByRole("button", { name: "Time range" });
expect(trigger).toHaveTextContent(/ to /);
expect(screen.getByTestId("traces-timeline")).toHaveTextContent("Total 1d");
fireEvent.click(trigger);
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last 7 days" }));
expect(await screen.findByText("Total 7d")).toBeInTheDocument();
const last = vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0];
expect((last?.endMs ?? 0) - (last?.startMs ?? 0)).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000);
await waitFor(() => {
const last = vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0];
expect((last?.endMs ?? 0) - (last?.startMs ?? 0)).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000);
});
const live = screen.getByRole("button", { name: "Live" });
expect(live).toHaveAttribute("aria-pressed", "true");
@ -671,7 +674,7 @@ describe("AgentTracesPage", () => {
fireEvent.click(screen.getByRole("button", { name: "Time range" }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last hour" }));
expect(await screen.findByText("No runs match these filters.")).toBeInTheDocument();
expect(await screen.findByText("No runs in this time range")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Time range" })).toBeInTheDocument();
expect(screen.queryByTestId("tracing-setup-card")).not.toBeInTheDocument();
});
@ -681,13 +684,15 @@ describe("AgentTracesPage", () => {
const onUrlUpdate = vi.fn();
renderWithProviders(<AgentTracesPage accessToken="sk-test" />, { searchParams: "?hours=168", onUrlUpdate });
await screen.findByTestId("runs-table");
expect(screen.getByTestId("traces-timeline")).toHaveTextContent("Total 7d");
const { startMs, endMs } = vi.mocked(agentTraceListCall).mock.calls[0][0];
expect(endMs - startMs).toBeGreaterThanOrEqual(7 * 24 * 3600 * 1000 - 60_000);
fireEvent.click(screen.getByRole("button", { name: "Time range" }));
fireEvent.click(await screen.findByRole("menuitemradio", { name: "Last hour" }));
expect(await screen.findByText("Total 1h")).toBeInTheDocument();
await waitFor(() => {
const last = vi.mocked(agentTraceListCall).mock.calls.at(-1)?.[0];
expect((last?.endMs ?? 0) - (last?.startMs ?? 0)).toBeLessThanOrEqual(3600 * 1000 + 60_000);
});
await waitFor(() => expect(lastUrl(onUrlUpdate).get("hours")).toBe("1"));
});

View file

@ -1,17 +0,0 @@
import { describe, expect, it } from "vitest";
import { filterRuns } from "./AgentTracesSection";
import traceList from "./__fixtures__/trace_list.json";
import type { TracePage, TraceSummary } from "./traceTypes";
const runs = (traceList as TracePage).items as TraceSummary[];
describe("filterRuns", () => {
it("status filter 'Failed' keeps only runs with errors", () => {
const failed = filterRuns(runs, "", "all", "error");
expect(failed.length).toBeGreaterThan(0);
expect(failed.every((r) => r.error_count > 0)).toBe(true);
const ok = filterRuns(runs, "", "all", "ok");
expect(ok.every((r) => r.error_count === 0)).toBe(true);
expect(failed.length + ok.length).toBe(runs.length);
});
});

View file

@ -3,12 +3,12 @@
import moment from "moment";
import { useMemo, useState } from "react";
import { HeaderActions } from "@/components/lens/HeaderActions";
import { filterRuns } from "@/components/lens/runSearch/runQuery";
import { RunsToolbar } from "@/components/lens/runSearch/RunsToolbar";
import { Button } from "@/components/ui/button";
import { AgentTracesTable } from "./AgentTracesTable";
import { RunDrawer } from "./RunDrawer";
import { ALL_AGENTS, RunsToolbar, type RunStatusFilter } from "./RunsToolbar";
import {
type TraceRef,
traceKey,
@ -18,32 +18,12 @@ import {
useZoomRouting,
} from "./traceRouting";
import type { TraceSummary } from "./traceTypes";
import { previewText, traceAgentNames } from "./traceUtils";
import { TimeRangeControls } from "./TimeRangeControls";
import { TracesTimeline, type TimeWindow } from "./TracesTimeline";
import { ActiveDot } from "./ActiveDot";
import { TracingSetupCard } from "./TracingSetupCard";
import { useTracesLive } from "./tracesApi";
import { type AgentTracesResult, traceWindowStartMs, useAgentTraces, useTraceAvailability } from "./useAgentTraces";
/** Client-side search (input text or trace id) plus agent / status filters over the loaded runs. */
export function filterRuns(
runs: TraceSummary[],
query: string,
agent: string,
status: RunStatusFilter,
): TraceSummary[] {
const q = query.trim().toLowerCase();
return runs.filter((run) => {
const haystack = [run.trace_id, previewText(run.input_preview), run.name].map((s) => s.toLowerCase());
const matchesQuery = !q || haystack.some((text) => text.includes(q));
const matchesAgent = agent === ALL_AGENTS || traceAgentNames(run).includes(agent);
const failed = run.error_count > 0;
const matchesStatus = status === "all" || (status === "error" ? failed : !failed);
return matchesQuery && matchesAgent && matchesStatus;
});
}
const filterByWindow = (runs: TraceSummary[], range: TimeWindow): TraceSummary[] =>
runs.filter((run) => {
const t = moment(run.start_time).valueOf();
@ -96,7 +76,7 @@ function TraceHistoryError({ history }: { history: ReturnType<typeof useTraceAva
);
}
/** The Runs view: filters, the runs table and footer — or one run, in place, once a row is clicked. */
/** The Runs view: filters and the runs table — or one run, in place, once a row is clicked. */
export function AgentTracesSection({
accessToken,
isActive,
@ -110,7 +90,7 @@ export function AgentTracesSection({
}: AgentTracesSectionProps) {
const live = useTracesLive();
const { trace: openTrace, openTrace: openRun, selection, fullScreen, setFullScreen } = useOpenTraceRouting();
const { query, agent, status, setQuery, setAgent, setStatus } = useRunFilterRouting();
const { query, setQuery } = useRunFilterRouting();
const [showSetup, setShowSetup] = useState(false);
const [zoom, setZoom] = useZoomRouting();
const [rangeChanged, setRangeChanged] = useState(false);
@ -125,17 +105,13 @@ export function AgentTracesSection({
if (setup.disabledDetail == null) void history.refetch();
};
const agents = useMemo(() => Array.from(new Set(traces.traces.flatMap(traceAgentNames))).sort(), [traces.traces]);
// Relative ranges end "now" (the list query uses Date.now() too); round to the minute so the histogram is stable.
const endMs = isCustomDate ? moment(endTime).valueOf() : moment().endOf("minute").valueOf();
const range = useMemo(
() => ({ startMs: traceWindowStartMs(startTime, endTime, isCustomDate, endMs), endMs }),
[startTime, endTime, isCustomDate, endMs],
);
const filtered = useMemo(
() => filterRuns(traces.traces, query, agent, status),
[traces.traces, query, agent, status],
);
const filtered = useMemo(() => filterRuns(traces.traces, query), [traces.traces, query]);
const runs = useMemo(() => (zoom ? filterByWindow(filtered, zoom) : filtered), [filtered, zoom]);
const changeRange = (hours: number, apply: (hours: number) => void) => {
@ -169,7 +145,7 @@ export function AgentTracesSection({
<button
type="button"
onClick={() => setShowSetup(false)}
className="mb-3 text-[13px] text-muted-foreground hover:text-foreground"
className="mb-3 text-sm text-muted-foreground hover:text-foreground"
>
← Back to traces
</button>
@ -184,10 +160,9 @@ export function AgentTracesSection({
};
return (
<div className="flex min-h-[560px] flex-1 flex-col overflow-hidden border-y border-border bg-card">
<div className="flex min-h-[560px] flex-1 flex-col overflow-hidden bg-card">
{checkHistory && <TraceHistoryError history={history} />}
<TracesReceived received={setup.received} />
<TracingSetupAction live={live} isActive={isActive} onClick={() => setShowSetup(true)} />
<RunDrawer
trace={openTrace}
runs={runs}
@ -197,15 +172,7 @@ export function AgentTracesSection({
fullScreen={fullScreen}
onFullScreenChange={setFullScreen}
/>
<RunsToolbar
query={query}
agent={agent}
status={status}
agents={agents}
onQueryChange={setQuery}
onAgentChange={setAgent}
onStatusChange={setStatus}
>
<RunsToolbar query={query} onQueryChange={setQuery} runs={traces.traces}>
{timeControls && (
<TimeRangeControls
range={zoom ?? range}
@ -234,30 +201,13 @@ export function AgentTracesSection({
onLoadMore={traces.loadMore}
onOpenTrace={toggleRun}
selectedKey={openTrace === null ? null : traceKey(openTrace)}
/>
<RunsFooter
count={runs.length}
zoom={zoom}
isFetching={traces.isFetching}
failed={!!traces.error}
onResetZoom={() => setZoom(null)}
rangeEmpty={traces.traces.length === 0}
onSetUpTracing={() => setShowSetup(true)}
/>
</div>
);
}
function TracingSetupAction({ live, isActive, onClick }: { live: boolean; isActive: boolean; onClick: () => void }) {
if (!live || !isActive) return null;
return (
<HeaderActions>
<Button variant="outline" size="sm" onClick={onClick} className="shrink-0 gap-1.5">
<ActiveDot />
Set up tracing
</Button>
</HeaderActions>
);
}
function TracesReceived({ received }: { received: boolean }) {
if (!received) return null;
return (
@ -266,39 +216,3 @@ function TracesReceived({ received }: { received: boolean }) {
</p>
);
}
function RunsFooter({
count,
zoom,
isFetching,
failed,
onResetZoom,
}: {
count: number;
zoom: TimeWindow | null;
isFetching: boolean;
failed: boolean;
onResetZoom: () => void;
}) {
const settled = failed ? "Update failed" : "Updated just now";
const status = isFetching ? "Updating…" : settled;
return (
<footer
data-testid="runs-footer"
className="flex h-8 shrink-0 items-center border-t border-border bg-muted/40 px-3 font-mono text-[11px] text-muted-foreground"
>
{count} {count === 1 ? "run" : "runs"}
{zoom && (
<button
type="button"
onClick={() => onResetZoom()}
aria-label="Clear time zoom"
className="ml-3 rounded border border-info/40 bg-info/10 px-1.5 text-info hover:bg-info/20"
>
{moment(zoom.startMs).format("MMM DD, HH:mm")} to {moment(zoom.endMs).format("MMM DD, HH:mm")} ×
</button>
)}
<span className="ml-auto">{status}</span>
</footer>
);
}

View file

@ -0,0 +1,69 @@
import { fireEvent, render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
import traceList from "./__fixtures__/trace_list.json";
import { AgentTracesTable } from "./AgentTracesTable";
import type { TracePage, TraceSummary } from "./traceTypes";
const renderEmpty = (rangeEmpty: boolean) => {
const onSetUpTracing = vi.fn();
render(
<AgentTracesTable
traces={[]}
isLoading={false}
error={null}
hasMore={false}
onLoadMore={vi.fn()}
onOpenTrace={vi.fn()}
rangeEmpty={rangeEmpty}
onSetUpTracing={onSetUpTracing}
/>,
);
return onSetUpTracing;
};
describe("AgentTracesTable empty state", () => {
it.each([
{ rangeEmpty: true, message: "No runs in this time range" },
{ rangeEmpty: false, message: "No runs match these filters." },
])("offers tracing setup when rangeEmpty is $rangeEmpty", ({ rangeEmpty, message }) => {
const onSetUpTracing = renderEmpty(rangeEmpty);
expect(screen.getByText(message)).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Set up tracing" }));
expect(onSetUpTracing).toHaveBeenCalledOnce();
});
});
describe("AgentTracesTable virtualization", () => {
const template = (traceList as TracePage).items[0] as TraceSummary;
const manyRuns: TraceSummary[] = Array.from({ length: 500 }, (_, i) => ({
...template,
trace_id: `trace-${i}`,
input_preview: `question ${i}`,
}));
it("renders only the rows near the viewport as the list scrolls", () => {
renderWithProviders(
<AgentTracesTable
traces={manyRuns}
isLoading={false}
error={null}
hasMore={false}
onLoadMore={vi.fn()}
onOpenTrace={vi.fn()}
onSetUpTracing={vi.fn()}
/>,
);
expect(screen.getAllByTestId("agent-trace-row").length).toBeLessThan(50);
expect(screen.getByText("question 0")).toBeInTheDocument();
const scroller = screen.getByTestId("runs-table");
scroller.scrollTop = 36 * 480;
fireEvent.scroll(scroller);
expect(screen.getAllByTestId("agent-trace-row").length).toBeLessThan(50);
expect(screen.getByText("question 499")).toBeInTheDocument();
expect(screen.queryByText("question 0")).not.toBeInTheDocument();
});
});

View file

@ -1,9 +1,11 @@
"use client";
import { ArrowDown, ChevronRight } from "lucide-react";
import { useVirtualizer } from "@tanstack/react-virtual";
import { useEffect, useState } from "react";
import { useInView } from "react-intersection-observer";
import { PANEL_TRIGGER } from "@/components/shared/SidePanel";
import { Button } from "@/components/ui/button";
import { formatActivityTimestamp, formatRunTimestamp, localTimeZoneAbbreviation } from "@/utils/activityTimestamp";
import { cn } from "@/lib/cva.config";
@ -25,6 +27,8 @@ interface AgentTracesTableProps {
onLoadMore: () => void;
onOpenTrace: (trace: TraceSummary) => void;
selectedKey?: string | null;
rangeEmpty?: boolean;
onSetUpTracing: () => void;
}
export const formatCost = (cost: number): string => {
@ -34,10 +38,13 @@ export const formatCost = (cost: number): string => {
};
const firstLine = (text: string): string => text.split("\n")[0] ?? text;
const runKey = (run: TraceSummary): string => run.trace_ref || run.trace_id;
const TH = "px-3 font-medium";
const PREFETCH_MARGIN = "0px 0px 480px 0px";
const PLACEHOLDER_ROWS = [0, 1, 2];
const ROW_HEIGHT = 36;
const OVERSCAN_ROWS = 12;
const TH_NUM = "px-3 text-right font-medium";
const TD_NUM = "px-3 text-right font-mono tabular-nums text-muted-foreground";
@ -71,6 +78,106 @@ function PlaceholderRow({ rowRef }: { rowRef?: (node: Element | null) => void })
);
}
function EmptyRuns({ rangeEmpty, onSetUpTracing }: { rangeEmpty: boolean; onSetUpTracing: () => void }) {
if (!rangeEmpty)
return (
<div className="flex items-center justify-center gap-3 py-16 text-xs text-muted-foreground">
<span>No runs match these filters.</span>
<Button size="xs" variant="outline" onClick={onSetUpTracing}>
Set up tracing
</Button>
</div>
);
return (
<div className="flex flex-col items-center gap-1 py-16 text-center">
<p className="text-sm font-medium">No runs in this time range</p>
<p className="text-xs text-muted-foreground">Connect an agent to start sending traces.</p>
<Button size="sm" className="mt-3" onClick={onSetUpTracing}>
Set up tracing
</Button>
</div>
);
}
function RunRow({
run,
selected,
onOpen,
}: {
run: TraceSummary;
selected: boolean;
onOpen: (run: TraceSummary) => void;
}) {
return (
<tr
data-testid="agent-trace-row"
{...PANEL_TRIGGER}
onClick={() => onOpen(run)}
aria-selected={selected}
className={cn(
"h-9 cursor-pointer border-b border-border/60 text-xs transition-colors duration-150 motion-reduce:transition-none",
selected ? "bg-trace-row-selected shadow-[inset_2px_0_0_var(--trace-brand)]" : "hover:bg-trace-row-hover",
)}
>
<td
className="px-3 font-mono text-xs whitespace-nowrap tabular-nums text-muted-foreground"
title={formatActivityTimestamp(run.start_time)}
>
{formatRunTimestamp(run.start_time)}
</td>
<AgentCell run={run} />
<td className="px-3">
<div className="flex min-w-0 items-center gap-2">
<StatusMark status={run.error_count > 0 ? "error" : "ok"} subtle />
<span className="truncate text-foreground">
{firstLine(previewText(run.input_preview)) || traceDisplayName(run)}
</span>
{run.resolution_limited && (
<span
className="shrink-0 text-xs text-muted-foreground"
title="This run is too large to calculate all totals in this view"
>
Partial totals
</span>
)}
<span className="hidden shrink-0 font-mono text-xs text-muted-foreground 2xl:inline">{run.trace_id}</span>
</div>
</td>
<td className={TD_NUM}>{run.agent_count.toLocaleString()}</td>
<td className={TD_NUM}>{run.span_count.toLocaleString()}</td>
<td className="px-3 text-right font-mono tabular-nums text-foreground">{fmtMs(run.duration_ms)}</td>
<td className="px-3 text-right font-mono tabular-nums text-foreground">
{run.spend == null ? "—" : formatCost(run.spend)}
</td>
<td className="px-3 text-right">
{run.error_count > 0 ? (
<StatusMark status="error" count={run.error_count} />
) : (
<span className="font-mono text-xs text-muted-foreground/60">0</span>
)}
</td>
<td>
<ChevronRight className="size-3 text-muted-foreground/60" />
</td>
</tr>
);
}
function useVirtualRows(traces: TraceSummary[], scroller: HTMLDivElement | null) {
const options = {
count: traces.length,
getScrollElement: () => scroller,
estimateSize: () => ROW_HEIGHT,
overscan: OVERSCAN_ROWS,
getItemKey: (index: number) => runKey(traces[index]),
};
const virtualizer = useVirtualizer(options);
const rows = virtualizer.getVirtualItems();
const padTop = rows[0]?.start ?? 0;
const padBottom = virtualizer.getTotalSize() - (rows.at(-1)?.end ?? 0);
return { rows, padTop, padBottom };
}
/** Devtool-dense runs list: one row per agent run, newest first. */
export function AgentTracesTable({
traces,
@ -83,6 +190,8 @@ export function AgentTracesTable({
onLoadMore,
onOpenTrace,
selectedKey = null,
rangeEmpty = false,
onSetUpTracing,
}: AgentTracesTableProps) {
const settled = !isLoading && !error;
const isEmpty = settled && !hasMore && traces.length === 0;
@ -93,6 +202,7 @@ export function AgentTracesTable({
useEffect(() => {
if (nearTail && autoContinue && !isFetching) onLoadMore();
}, [nearTail, autoContinue, isFetching, onLoadMore]);
const { rows, padTop, padBottom } = useVirtualRows(traces, scroller);
return (
<div ref={setScroller} className="min-h-0 flex-1 overflow-auto" data-testid="runs-table">
<table
@ -101,7 +211,7 @@ export function AgentTracesTable({
className="w-full min-w-[900px] table-fixed border-collapse text-left"
>
<thead className="sticky top-0 z-sticky bg-[color-mix(in_oklab,var(--muted)_40%,var(--card))]">
<tr className="h-8 border-b border-border text-[10px] tracking-[0.08em] text-muted-foreground uppercase">
<tr className="h-8 border-b border-border text-xs tracking-wider text-muted-foreground uppercase">
<th className={`w-[170px] ${TH}`}>
<span className="inline-flex items-center gap-1 whitespace-nowrap">
Time <ArrowDown className="size-2.5" />
@ -121,70 +231,18 @@ export function AgentTracesTable({
</tr>
</thead>
<tbody>
{traces.map((run) => (
<tr
key={run.trace_ref || run.trace_id}
data-testid="agent-trace-row"
onClick={() => onOpenTrace(run)}
aria-selected={selectedKey === (run.trace_ref || run.trace_id)}
className={cn(
"h-9 cursor-pointer border-b border-border/60 text-[12px] transition-colors duration-150 motion-reduce:transition-none",
selectedKey === (run.trace_ref || run.trace_id)
? "bg-trace-row-selected shadow-[inset_2px_0_0_var(--trace-brand)]"
: "hover:bg-trace-row-hover",
)}
>
<td
className="px-3 font-mono text-[11px] whitespace-nowrap tabular-nums text-muted-foreground"
title={formatActivityTimestamp(run.start_time)}
>
{formatRunTimestamp(run.start_time)}
</td>
<AgentCell run={run} />
<td className="px-3">
<div className="flex min-w-0 items-center gap-2">
<StatusMark status={run.error_count > 0 ? "error" : "ok"} subtle />
<span className="truncate text-foreground">
{firstLine(previewText(run.input_preview)) || traceDisplayName(run)}
</span>
{run.resolution_limited && (
<span
className="shrink-0 text-[10px] text-muted-foreground"
title="This run is too large to calculate all totals in this view"
>
Partial totals
</span>
)}
<span className="hidden shrink-0 font-mono text-[10px] text-muted-foreground 2xl:inline">
{run.trace_id}
</span>
</div>
</td>
<td className={TD_NUM}>{run.agent_count.toLocaleString()}</td>
<td className={TD_NUM}>{run.span_count.toLocaleString()}</td>
<td className="px-3 text-right font-mono tabular-nums text-foreground">{fmtMs(run.duration_ms)}</td>
<td className="px-3 text-right font-mono tabular-nums text-foreground">
{run.spend == null ? "—" : formatCost(run.spend)}
</td>
<td className="px-3 text-right">
{run.error_count > 0 ? (
<StatusMark status="error" count={run.error_count} />
) : (
<span className="font-mono text-[11px] text-muted-foreground/60">0</span>
)}
</td>
<td>
<ChevronRight className="size-3 text-muted-foreground/60" />
</td>
</tr>
{padTop > 0 && <tr aria-hidden style={{ height: padTop }} />}
{rows.map(({ index, key }) => (
<RunRow key={key} run={traces[index]} selected={selectedKey === key} onOpen={onOpenTrace} />
))}
{padBottom > 0 && <tr aria-hidden style={{ height: padBottom }} />}
{autoContinue &&
PLACEHOLDER_ROWS.map((row) => <PlaceholderRow key={row} rowRef={row === 0 ? tailRef : undefined} />)}
</tbody>
</table>
{isLoading && <div className="py-16 text-center text-[12px] text-muted-foreground">Loading runs…</div>}
{isLoading && <div className="py-16 text-center text-xs text-muted-foreground">Loading runs…</div>}
{error && (
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-[12px] text-muted-foreground">
<div role="alert" className="flex items-center justify-center gap-3 py-6 text-xs text-muted-foreground">
<span>
{traces.length ? "Could not load more runs" : "Could not load runs"}: {error.message}
</span>
@ -196,16 +254,14 @@ export function AgentTracesTable({
</div>
)}
{canContinue && traces.length === 0 && (
<div className="flex items-center justify-center gap-3 py-16 text-[12px] text-muted-foreground">
<div className="flex items-center justify-center gap-3 py-16 text-xs text-muted-foreground">
<span>No loaded runs match these filters.</span>
<Button size="xs" variant="outline" disabled={isFetching} onClick={onLoadMore}>
Load older runs
</Button>
</div>
)}
{isEmpty && (
<div className="py-16 text-center text-[12px] text-muted-foreground">No runs match these filters.</div>
)}
{isEmpty && <EmptyRuns rangeEmpty={rangeEmpty} onSetUpTracing={onSetUpTracing} />}
</div>
);
}

View file

@ -14,7 +14,7 @@ interface AttributesDetailProps {
export function DetailGroup({ title, children }: { title: string; children: React.ReactNode }) {
return (
<section aria-label={title} className="flex flex-col gap-2">
<h3 className="py-1 text-[13px] font-medium tracking-[-0.26px] text-trace-text">{title}</h3>
<h3 className="py-1 text-sm font-medium text-trace-text">{title}</h3>
<Card className="px-3 py-2.5">{children}</Card>
</section>
);
@ -38,7 +38,7 @@ export function AttributesDetail({ traceId, span, attributes, isLoading }: Attri
<KeyValueRows entries={attributeEntries} />
</DetailGroup>
)}
{isLoading && <div className="text-[13px] text-trace-duration">Loading attributes…</div>}
{isLoading && <div className="text-sm text-trace-duration">Loading attributes…</div>}
</div>
);
}

View file

@ -16,9 +16,8 @@ import { errorSource, parseJson, parseMessages, prettyPayload } from "./traceUti
const ERROR_SOURCE_LABEL: Record<ErrorSource, string> = { tool: "Tool", model: "Model", litellm: "LiteLLM" };
const TRACEBACK_MARKER = "Traceback (most recent call last):";
const STATUS_TEXT = "px-5 py-2 text-[13px] tracking-[-0.26px] text-muted-foreground";
const PAYLOAD_PRE =
"font-mono text-[13px] leading-[1.5] tracking-[-0.26px] break-words whitespace-pre-wrap text-foreground";
const STATUS_TEXT = "px-3 py-2 text-sm text-muted-foreground";
const PAYLOAD_PRE = "font-mono text-sm leading-normal break-words whitespace-pre-wrap text-foreground";
/** Exporters record `repr(exc)` + traceback with no separator; keep the exception line. */
export const errorHeadline = (error: string): string =>
@ -46,9 +45,9 @@ export function ErrorBlock({ span }: { span: Span }) {
return (
<section
aria-label="Error"
className="mx-3 mb-3 rounded-[4px] border-[0.67px] border-destructive/40 bg-destructive/5 px-3 py-2.5"
className="mx-3 mb-3 rounded-sm border border-destructive/40 bg-destructive/5 px-3 py-2.5"
>
<div className="flex items-center gap-2 text-[13px] leading-[1.2] font-medium tracking-[-0.26px] text-destructive">
<div className="flex items-center gap-2 text-sm leading-tight font-medium text-destructive">
<AlertTriangle className="size-3.5" />
{ERROR_SOURCE_LABEL[source]} · {errorReason(headline)}
</div>
@ -210,7 +209,7 @@ export function DetailContent({ accessToken, traceId, traceRef, span }: DetailCo
: parseMessages(detail?.input ?? "")?.length;
return (
<div className="flex flex-col px-2 pt-1 pb-4">
<div className="flex flex-col pt-1 pb-4">
<ErrorBlock span={span} />
{span.error && (
<DiagnosticContent
@ -239,9 +238,7 @@ export function DetailContent({ accessToken, traceId, traceRef, span }: DetailCo
</Section>
) : null}
{empty && span.status !== "error" && (
<div className="py-12 text-center text-[13px] tracking-[-0.26px] text-muted-foreground">
No content recorded for this span.
</div>
<div className="py-12 text-center text-sm text-muted-foreground">No content recorded for this span.</div>
)}
</div>
);

View file

@ -9,6 +9,7 @@ import { AttributesDetail } from "./AttributesDetail";
import { CopyButton } from "./CopyButton";
import { DetailContent, errorHeadline, useSpanDetail } from "./DetailContent";
import { IdChip } from "./IdChip";
import { PaneBar } from "./PaneBar";
import { RequestDetail } from "./RequestDetail";
import { SpanIcon } from "./SpanIcon";
import { useTracesApi } from "./tracesApi";
@ -47,7 +48,7 @@ function PaneHeader({
onClose: () => void;
}) {
return (
<div className="flex h-7 shrink-0 items-center justify-between gap-3 px-5">
<PaneBar className="justify-between gap-3">
<div className="flex min-w-0 items-center">
<span className="mr-2 shrink-0">
<SpanIcon type={type} model={model} error={failed} size="md" />
@ -64,20 +65,16 @@ function PaneHeader({
size="icon-xs"
onClick={onClose}
aria-label="Close details"
className="size-6 shrink-0 rounded-[4px] text-muted-foreground"
className="size-6 shrink-0 rounded-sm text-muted-foreground"
>
<PanelRightClose className="size-4" />
</Button>
</div>
</PaneBar>
);
}
function PaneFooter({ children }: { children: React.ReactNode }) {
return (
<div className="flex min-h-10 shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-t bg-background px-5 py-2">
{children}
</div>
);
return <div className="flex h-10 shrink-0 items-center gap-3 border-t bg-background px-3">{children}</div>;
}
function Meta({ label, value }: { label: string; value: string }) {
@ -112,10 +109,7 @@ function SpanPane({
);
const tokens = span.input_tokens + span.output_tokens;
return (
<aside
className="flex h-full min-w-0 flex-col bg-background pt-4 text-[13px] text-foreground"
aria-label="Span details"
>
<aside className="flex h-full min-w-0 flex-col bg-background text-sm text-foreground" aria-label="Span details">
<PaneHeader
type={span.type}
model={span.model}
@ -125,15 +119,15 @@ function SpanPane({
onClose={onClose}
/>
<Tabs value={tab} onValueChange={(value) => onSpanTabChange(value as SpanTab)} className="min-h-0 flex-1 gap-0">
<div className="shrink-0 border-b px-5">
<TabsList variant="line" aria-label="Span detail sections" className="h-10 gap-4 px-0">
<PaneBar>
<TabsList variant="line" aria-label="Span detail sections" className="h-full gap-4 px-0">
{SPAN_TABS.map((id) => (
<TabsTrigger key={id} value={id} className="flex-none px-0 text-xs">
{TAB_LABELS[id]}
</TabsTrigger>
))}
</TabsList>
</div>
</PaneBar>
<TabsContent value="content" className="min-h-0 overflow-auto pt-3">
<DetailContent accessToken={accessToken} traceId={traceId} traceRef={trace.summary.trace_ref} span={span} />
</TabsContent>
@ -163,8 +157,8 @@ function SpanPane({
function GroupMetric({ label, value }: { label: string; value: string }) {
return (
<div className="border-b border-border/60 py-3">
<div className="text-[13px] font-medium text-muted-foreground">{label}</div>
<div className="mt-1 text-[13px] text-foreground tabular-nums">{value}</div>
<div className="text-sm font-medium text-muted-foreground">{label}</div>
<div className="mt-1 text-sm text-foreground tabular-nums">{value}</div>
</div>
);
}
@ -186,10 +180,7 @@ function GroupPane({
const sample = (firstFailure ?? row.members[0]).span_id;
const handoff = useTracesApi(accessToken).handoff(trace.summary.trace_id, sample, trace.summary.trace_ref);
return (
<aside
className="flex h-full min-w-0 flex-col bg-background pt-4 text-[13px] text-foreground"
aria-label="Group details"
>
<aside className="flex h-full min-w-0 flex-col bg-background text-sm text-foreground" aria-label="Group details">
<PaneHeader
type={row.type}
model={row.members[0]?.model ?? null}
@ -201,7 +192,7 @@ function GroupPane({
}
onClose={onClose}
/>
<div className="min-h-0 flex-1 overflow-auto px-5 py-3">
<div className="min-h-0 flex-1 overflow-auto px-3 py-3">
<div className="grid grid-cols-2 gap-x-5">
<GroupMetric label="Invocations" value={row.members.length.toLocaleString()} />
<GroupMetric label="Failed" value={row.failedCount.toLocaleString()} />
@ -211,9 +202,9 @@ function GroupPane({
<GroupMetric label="Type" value={row.type} />
</div>
{firstFailure?.error && (
<section className="mt-3 rounded-[4px] border border-destructive/30 px-3.5 py-3">
<div className="text-[12px] font-medium text-destructive">Failure pattern</div>
<p className="mt-2 font-mono text-[12px] leading-5 text-foreground">{errorHeadline(firstFailure.error)}</p>
<section className="mt-3 rounded-sm border border-destructive/30 px-3.5 py-3">
<div className="text-xs font-medium text-destructive">Failure pattern</div>
<p className="mt-2 font-mono text-xs leading-5 text-foreground">{errorHeadline(firstFailure.error)}</p>
</section>
)}
</div>
@ -228,7 +219,7 @@ function GroupPane({
export function DetailPane({ trace, row, accessToken, spanTab, onSpanTabChange, onClose }: DetailPaneProps) {
if (!row || row.kind === "load-more") {
return (
<div className="grid h-full place-items-center bg-background text-[13px] text-muted-foreground">
<div className="grid h-full place-items-center bg-background text-sm text-muted-foreground">
Select a span to inspect it.
</div>
);

View file

@ -35,7 +35,7 @@ function KeyValueRow({ entry, mono }: { entry: KeyValue; mono: boolean }) {
const [key, value] = entry;
const [open, setOpen] = useState(false);
const valueClass = cn(
"min-w-0 flex-1 break-words text-left text-[13px] leading-5 text-foreground",
"min-w-0 flex-1 break-words text-left text-sm leading-5 text-foreground",
mono || ID_KEY.test(key) ? "font-mono" : "font-sans",
);
@ -72,7 +72,7 @@ function KeyValueRow({ entry, mono }: { entry: KeyValue; mono: boolean }) {
<button {...toggle} className="flex shrink-0 cursor-pointer items-baseline gap-2.5 text-left">
{head}
</button>
<pre className={cn(valueClass, "leading-[1.5] break-words whitespace-pre-wrap")}>{value}</pre>
<pre className={cn(valueClass, "leading-normal break-words whitespace-pre-wrap")}>{value}</pre>
</>
) : (
<button {...toggle} className="flex min-w-0 flex-1 cursor-pointer items-baseline gap-2.5 text-left">

View file

@ -31,7 +31,7 @@ const markdownComponents: Components = {
<pre
className={cn(
BLOCK_GAP,
"max-w-full overflow-x-auto rounded-[4px] bg-muted/40 p-2.5 font-mono text-[13px] leading-[1.5] tracking-normal",
"max-w-full overflow-x-auto rounded-sm bg-muted/40 p-2.5 font-mono text-sm leading-normal tracking-normal",
)}
{...props}
/>
@ -55,7 +55,7 @@ export function Card({ children, className }: { children: React.ReactNode; class
return <section className={cn("rounded-md border border-border bg-background", className)}>{children}</section>;
}
const TILE = "grid size-4 shrink-0 place-items-center rounded-[2px] p-0.5";
const TILE = "grid size-4 shrink-0 place-items-center rounded-sm p-0.5";
function RoleTile({ failed = false }: { role: string; failed?: boolean }) {
return <Wrench className={cn("size-3.5 shrink-0", failed ? "text-destructive" : "text-muted-foreground")} />;
@ -83,8 +83,8 @@ const HEADER =
"group/header flex min-w-0 items-center gap-2 border border-border bg-background py-2 pr-2 pl-3 transition-colors hover:bg-muted/30";
const LABEL = "min-w-0 truncate text-xs leading-5 font-medium text-foreground";
const CARD_COPY =
"ml-auto size-5 shrink-0 rounded-[3px] text-muted-foreground opacity-60 transition-opacity duration-150 group-hover/header:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [&_svg]:size-4";
const INLINE_COPY = "size-4 shrink-0 rounded-[3px] text-muted-foreground";
"ml-auto size-5 shrink-0 rounded-sm text-muted-foreground opacity-60 transition-opacity duration-150 group-hover/header:opacity-100 focus-visible:opacity-100 motion-reduce:transition-none [&_svg]:size-4";
const INLINE_COPY = "size-4 shrink-0 rounded-sm text-muted-foreground";
export function ToolCallBlock({ call }: { call: TraceToolCall }) {
const argEntries = objectEntries(call.args);
@ -111,7 +111,7 @@ export function MessageCard({ message }: { message: TraceMessage; model: string
const expanded = open && hasBody;
return (
<article className="rounded-md">
<div className={cn(HEADER, "sticky top-10 z-sticky", expanded ? "rounded-t-[4px] border-b-0" : "rounded-[4px]")}>
<div className={cn(HEADER, "sticky top-10 z-sticky", expanded ? "rounded-t-sm border-b-0" : "rounded-sm")}>
<FoldTile label={label} open={open} onToggle={() => setOpen((v) => !v)} />
<span className={LABEL}>{label}</span>
<CopyButton value={copyValue} label={`Copy ${label}`} iconOnly className={CARD_COPY} />
@ -142,7 +142,7 @@ export function ToolResultCard({ name, result, failed = false }: { name: string;
const tone = failed ? "text-destructive" : "text-muted-foreground";
return (
<article className="flex flex-col">
<div className={cn(HEADER, open ? "rounded-t-[4px]" : "rounded-[4px]")}>
<div className={cn(HEADER, open ? "rounded-t-sm" : "rounded-sm")}>
<RoleTile role="tool" failed={failed} />
<span className="flex min-w-0 max-w-[50%] shrink-0 items-center gap-1.5">
<span className={cn(LABEL, failed && "text-destructive")}>{name}</span>
@ -157,17 +157,17 @@ export function ToolResultCard({ name, result, failed = false }: { name: string;
className={cn("flex min-w-0 cursor-pointer items-center gap-1 text-left", tone)}
>
<FoldChevron open={open} className="size-3 shrink-0" />
{!open && <span className="min-w-0 truncate text-[13px] leading-[1.2]">{result}</span>}
{!open && <span className="min-w-0 truncate text-sm leading-tight">{result}</span>}
</button>
) : (
<span className={cn("min-w-0 break-words text-[13px] leading-5", tone)}>{result || "No output"}</span>
<span className={cn("min-w-0 break-words text-sm leading-5", tone)}>{result || "No output"}</span>
)}
<CopyButton value={result} label={`Copy ${name} result`} iconOnly className={CARD_COPY} />
</div>
{open && (
<pre
className={cn(
"max-h-80 overflow-auto rounded-b-[4px] border-[0.67px] border-t-0 border-border bg-muted/40 px-3 py-2 font-mono text-[12px] leading-[1.5] break-words whitespace-pre-wrap",
"max-h-80 overflow-auto rounded-b-sm border border-t-0 border-border bg-muted/40 px-3 py-2 font-mono text-xs leading-normal break-words whitespace-pre-wrap",
tone,
)}
>
@ -197,10 +197,10 @@ export function Section({
type="button"
aria-expanded={open}
onClick={() => setOpen((prev) => !prev)}
className="sticky top-0 z-sticky-pinned mb-2 flex h-10 w-full cursor-pointer items-center gap-2.5 bg-background py-1 pr-3 pl-2 text-left transition-colors duration-150 hover:bg-muted/40 motion-reduce:transition-none"
className="sticky top-0 z-sticky-pinned mb-2 flex h-10 w-full cursor-pointer items-center gap-2.5 bg-background px-3 text-left transition-colors duration-150 hover:bg-muted/40 motion-reduce:transition-none"
>
<FoldChevron open={open} className="size-3 shrink-0 text-foreground" />
<span className="text-[13px] leading-[1.2] font-medium tracking-[-0.26px] text-foreground">{title}</span>{" "}
<span className="text-sm leading-tight font-medium text-foreground">{title}</span>{" "}
{count !== undefined && (
<span className="text-xs font-normal text-muted-foreground">
{count} {count === 1 ? "message" : "messages"}

View file

@ -0,0 +1,5 @@
import { cn } from "@/lib/cva.config";
export function PaneBar({ children, className }: { children: React.ReactNode; className?: string }) {
return <div className={cn("flex h-10 shrink-0 items-center gap-2 border-b px-3", className)}>{children}</div>;
}

View file

@ -30,7 +30,7 @@ export function RequestDetail({ span, accessToken, traceStartMs }: RequestDetail
const logNotFound = lookupDone && logQuery.data === null;
if (span.type !== "llm") {
return <div className="py-16 text-center text-[13px] text-trace-duration">This span is not a model request.</div>;
return <div className="py-16 text-center text-sm text-trace-duration">This span is not a model request.</div>;
}
const usage: KeyValue[] = [
@ -57,15 +57,15 @@ export function RequestDetail({ span, accessToken, traceStartMs }: RequestDetail
<Button
variant="outline"
size="xs"
className="self-start border-trace-border text-[13px] text-trace-text-2 shadow-trace-xs"
className="self-start border-trace-border text-sm text-trace-text-2 shadow-trace-xs"
onClick={() => setDrawerOpen(true)}
>
Open request log <ArrowUpRight className="size-3.5" />
</Button>
{logNotFound && <p className="text-[13px] text-trace-duration">No request log found for this call.</p>}
{logNotFound && <p className="text-sm text-trace-duration">No request log found for this call.</p>}
</div>
) : (
<p className="text-[13px] text-trace-duration">Not linked to a LiteLLM request</p>
<p className="text-sm text-trace-duration">Not linked to a LiteLLM request</p>
)}
</DetailGroup>
<LogDetailsDrawer

View file

@ -2,7 +2,8 @@ import { fireEvent, screen, waitFor } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "../../../../tests/test-utils";
import { clampDrawerWidth, RunDrawer } from "./RunDrawer";
import { clampDrawerWidth, PANEL_TRIGGER } from "@/components/shared/SidePanel";
import { RunDrawer } from "./RunDrawer";
import { type RunSelection, traceRefOf, useOpenTraceRouting } from "./traceRouting";
import type { TraceSummary } from "./traceTypes";
@ -62,11 +63,15 @@ const lastUrl = (onUrlUpdate: ReturnType<typeof vi.fn>) =>
new URLSearchParams(String(onUrlUpdate.mock.lastCall?.[0].queryString ?? ""));
const mockReducedMotion = (reduce: boolean) =>
vi
.spyOn(window, "matchMedia")
.mockImplementation(
(query: string) => ({ matches: reduce && query.includes("reduce"), media: query }) as MediaQueryList,
);
vi.spyOn(window, "matchMedia").mockImplementation(
(query: string) =>
({
matches: reduce && query.includes("reduce"),
media: query,
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
}) as unknown as MediaQueryList,
);
describe("clampDrawerWidth", () => {
it("keeps at least 700px and a 100px strip of list on wide screens", () => {
@ -156,6 +161,70 @@ describe("RunDrawer", () => {
expect(onSelect).toHaveBeenCalledExactlyOnceWith(null);
});
it("leaves J, K and Escape to an open menu or listbox instead of stepping or closing the trace", () => {
const runs = [run("a"), run("b"), run("c")];
const onSelect = vi.fn();
renderWithProviders(
<>
<div role="menu">
<button type="button">menu item</button>
</div>
<div role="listbox">
<button type="button">option</button>
</div>
<RunDrawer
trace={traceRefOf(runs[1])}
runs={runs}
accessToken="sk"
selection={selection}
onSelect={onSelect}
fullScreen={false}
onFullScreenChange={vi.fn()}
/>
</>,
);
for (const target of [screen.getByText("menu item"), screen.getByText("option")]) {
for (const key of ["j", "k", "Escape"]) fireEvent.keyDown(target, { key });
}
expect(onSelect).not.toHaveBeenCalled();
fireEvent.keyDown(window, { key: "j" });
expect(onSelect).toHaveBeenCalledExactlyOnceWith(traceRefOf(runs[2]));
});
it("closes on a press outside the panel but not inside it, on a trigger row, or in a dialog", () => {
const runs = [run("a")];
const onSelect = vi.fn();
renderWithProviders(
<>
<button type="button">outside</button>
<table>
<tbody>
<tr {...PANEL_TRIGGER}>
<td>row</td>
</tr>
</tbody>
</table>
<div role="dialog">dialog</div>
<RunDrawer
trace={traceRefOf(runs[0])}
runs={runs}
accessToken="sk"
selection={selection}
onSelect={onSelect}
fullScreen={false}
onFullScreenChange={vi.fn()}
/>
</>,
);
fireEvent.mouseDown(screen.getByTestId("run-view"));
fireEvent.mouseDown(screen.getByText("row"));
fireEvent.mouseDown(screen.getByText("dialog"));
fireEvent.mouseDown(screen.getByText("outside"), { button: 2 });
expect(onSelect).not.toHaveBeenCalled();
fireEvent.mouseDown(screen.getByText("outside"));
expect(onSelect).toHaveBeenCalledExactlyOnceWith(null);
});
it("unmounts right away on close when the user prefers reduced motion", () => {
mockReducedMotion(true);
const runs = [run("a")];

View file

@ -1,143 +1,12 @@
"use client";
import { ChevronDown, ChevronsRight, ChevronUp, Maximize2, Minimize2, X } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useCallback } from "react";
import { cn } from "@/lib/cva.config";
import { SidePanel } from "@/components/shared/SidePanel";
import { RunView } from "./TraceDrawer";
import { type RunSelection, type TraceRef, traceKey, traceRefOf } from "./traceRouting";
import type { TraceSummary } from "./traceTypes";
import { ignoresLetterShortcut } from "../letterShortcut";
const WIDTH_KEY = "litellm.agentTraces.drawerWidth";
const MIN_WIDTH = 700;
const MIN_LEFT_GAP = 100;
const DEFAULT_FRACTION = 0.56;
const KEY_STEP = 32;
const viewportWidth = (): number => (typeof window === "undefined" ? 1440 : window.innerWidth);
/** At least 700px when the screen allows, never wider than the screen minus a 100px strip of list (or the full screen). */
export const clampDrawerWidth = (width: number, viewport: number): number => {
const max = Math.max(Math.min(MIN_WIDTH, viewport), viewport - MIN_LEFT_GAP);
return Math.round(Math.min(Math.max(width, Math.min(MIN_WIDTH, max)), max));
};
const prefersReducedMotion = (): boolean =>
typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true;
const readStoredWidth = (): number | null => {
try {
const raw = window.localStorage.getItem(WIDTH_KEY);
const parsed = raw === null ? Number.NaN : Number(raw);
return Number.isFinite(parsed) ? parsed : null;
} catch {
return null;
}
};
const storeWidth = (width: number): void => {
try {
window.localStorage.setItem(WIDTH_KEY, String(width));
} catch {
return;
}
};
function useDrawerWidth() {
const [width, setWidth] = useState(() =>
clampDrawerWidth(readStoredWidth() ?? viewportWidth() * DEFAULT_FRACTION, viewportWidth()),
);
const update = useCallback((next: number) => {
const clamped = clampDrawerWidth(next, viewportWidth());
setWidth(clamped);
storeWidth(clamped);
}, []);
useEffect(() => {
const onResize = () => setWidth((w) => clampDrawerWidth(w, viewportWidth()));
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize);
}, []);
return [width, update] as const;
}
function ResizeHandle({ width, onResize }: { width: number; onResize: (width: number) => void }) {
const [dragging, setDragging] = useState(false);
const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
event.preventDefault();
event.currentTarget.setPointerCapture(event.pointerId);
setDragging(true);
};
const onPointerMove = (event: React.PointerEvent<HTMLDivElement>) => {
if (dragging) onResize(viewportWidth() - event.clientX);
};
const onKeyDown = (event: React.KeyboardEvent<HTMLDivElement>) => {
if (event.key === "ArrowLeft") onResize(width + KEY_STEP);
else if (event.key === "ArrowRight") onResize(width - KEY_STEP);
else return;
event.preventDefault();
event.stopPropagation();
};
return (
<div
role="separator"
aria-orientation="vertical"
aria-label="Resize trace panel"
aria-valuenow={width}
aria-valuemin={MIN_WIDTH}
tabIndex={0}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={() => setDragging(false)}
onPointerCancel={() => setDragging(false)}
onKeyDown={onKeyDown}
className="group/handle absolute inset-y-0 -left-2.5 z-raised flex w-5 cursor-col-resize justify-center outline-none"
>
<span
className={cn(
"h-full w-[0.67px] bg-border transition-[width,background-color] duration-150 group-hover/handle:w-0.5 group-focus-visible/handle:w-0.5 group-focus-visible/handle:bg-trace-brand motion-reduce:transition-none",
dragging && "w-0.5 bg-trace-brand",
)}
/>
</div>
);
}
function HeaderButton({
label,
disabled = false,
onClick,
children,
}: {
label: string;
disabled?: boolean;
onClick: () => void;
children: React.ReactNode;
}) {
return (
<button
type="button"
aria-label={label}
title={label}
disabled={disabled}
onClick={onClick}
className="grid size-7 place-items-center rounded-[4px] text-muted-foreground transition-colors duration-150 hover:bg-muted hover:text-foreground disabled:pointer-events-none disabled:opacity-40 motion-reduce:transition-none"
>
{children}
</button>
);
}
function FullScreenButton({ fullScreen, onToggle }: { fullScreen: boolean; onToggle: () => void }) {
return (
<HeaderButton label={fullScreen ? "Exit full screen" : "Enter full screen"} onClick={onToggle}>
{fullScreen ? <Minimize2 className="size-4" /> : <Maximize2 className="size-4" />}
</HeaderButton>
);
}
interface RunDrawerProps {
trace: TraceRef | null;
@ -159,15 +28,6 @@ export function RunDrawer({
fullScreen,
onFullScreenChange,
}: RunDrawerProps) {
const [width, setWidth] = useDrawerWidth();
const [lastShown, setLastShown] = useState<TraceRef | null>(trace);
const [exitedKey, setExitedKey] = useState<string | null>(null);
if (trace !== null && trace !== lastShown) setLastShown(trace);
const shown = trace ?? lastShown;
const closing = trace === null && shown !== null;
if (trace !== null && exitedKey !== null) setExitedKey(null);
if (closing && exitedKey !== traceKey(shown) && prefersReducedMotion()) setExitedKey(traceKey(shown));
const index = trace === null ? -1 : runs.findIndex((run) => traceKey(traceRefOf(run)) === traceKey(trace));
const step = useCallback(
(delta: number) => {
@ -176,75 +36,31 @@ export function RunDrawer({
},
[runs, index, onSelect],
);
useEffect(() => {
if (trace === null) return;
const onKeyDown = (event: KeyboardEvent) => {
if (ignoresLetterShortcut(event)) return;
if (event.key === "Escape") {
event.preventDefault();
onSelect(null);
} else if (event.key === "j") {
event.preventDefault();
step(1);
} else if (event.key === "k") {
event.preventDefault();
step(-1);
}
};
window.addEventListener("keydown", onKeyDown);
return () => window.removeEventListener("keydown", onKeyDown);
}, [trace, step, onSelect]);
if (shown === null || (closing && exitedKey === traceKey(shown))) return null;
const close = useCallback(() => onSelect(null), [onSelect]);
return (
<aside
aria-label="Trace details"
data-testid="run-drawer"
style={{ width: fullScreen ? "100%" : width }}
onAnimationEnd={(event) => {
if (closing && event.target === event.currentTarget) setExitedKey(traceKey(shown));
}}
className={cn(
"fixed inset-y-0 right-0 z-overlay flex origin-right flex-col bg-background shadow-[0_10px_15px_-3px_rgba(16,24,40,0.1),0_4px_6px_-4px_rgba(16,24,40,0.1)] motion-reduce:animate-none",
closing ? "animate-trace-drawer-out" : "animate-trace-drawer-in",
)}
<SidePanel
item={trace}
itemKey={traceKey}
noun="trace"
label="Trace details"
testId="run-drawer"
index={index}
total={runs.length}
onStep={step}
onClose={close}
fullScreen={fullScreen}
onFullScreenChange={onFullScreenChange}
>
{!fullScreen && <ResizeHandle width={width} onResize={setWidth} />}
<div className="flex h-[37px] shrink-0 items-center gap-1 border-b border-border px-2">
<HeaderButton label="Close (Esc)" onClick={() => onSelect(null)}>
<ChevronsRight className="size-4" />
</HeaderButton>
<span className="mx-1 h-4 w-px bg-border" />
<HeaderButton label="Next trace (J)" disabled={index < 0 || index >= runs.length - 1} onClick={() => step(1)}>
<ChevronDown className="size-4" />
</HeaderButton>
<HeaderButton label="Previous trace (K)" disabled={index <= 0} onClick={() => step(-1)}>
<ChevronUp className="size-4" />
</HeaderButton>
{index >= 0 && (
<span className="ml-1 font-mono text-[11px] text-muted-foreground tabular-nums">
{index + 1} / {runs.length}
</span>
)}
<div className="ml-auto flex items-center gap-1">
<FullScreenButton fullScreen={fullScreen} onToggle={() => onFullScreenChange(!fullScreen)} />
<HeaderButton label="Close trace (Esc)" onClick={() => onSelect(null)}>
<X className="size-4" />
</HeaderButton>
</div>
</div>
<div className="flex min-h-0 flex-1 flex-col">
{(shown) => (
<RunView
key={traceKey(shown)}
traceId={shown.traceId}
traceRef={shown.traceRef}
selection={selection}
accessToken={accessToken}
onBack={() => onSelect(null)}
onBack={close}
embedded
/>
</div>
</aside>
)}
</SidePanel>
);
}

View file

@ -1,89 +0,0 @@
"use client";
import { Search } from "lucide-react";
import { Input } from "@/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
export type RunStatusFilter = "all" | "ok" | "error";
export const ALL_AGENTS = "all";
interface RunsToolbarProps {
query: string;
agent: string;
status: RunStatusFilter;
agents: string[];
onQueryChange: (value: string) => void;
onAgentChange: (value: string) => void;
onStatusChange: (value: RunStatusFilter) => void;
/** Extra controls (time range, live tail) rendered on the right. */
children?: React.ReactNode;
}
const STATUS_ITEMS: { value: RunStatusFilter; label: string }[] = [
{ value: "all", label: "All status" },
{ value: "ok", label: "Succeeded" },
{ value: "error", label: "Failed" },
];
/** Search + agent / status filters for the Runs table. Filtering is client-side over the loaded page. */
export function RunsToolbar({
query,
agent,
status,
agents,
onQueryChange,
onAgentChange,
onStatusChange,
children,
}: RunsToolbarProps) {
const agentItems = [{ value: ALL_AGENTS, label: "All agents" }, ...agents.map((s) => ({ value: s, label: s }))];
return (
<div className="flex min-h-11 shrink-0 flex-wrap items-center gap-2 border-b border-border bg-card px-3 py-2">
<div className="relative w-full max-w-[380px]">
<Search className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search input or trace ID"
aria-label="Search runs"
className="h-7 pl-8 text-[12px]"
/>
</div>
<Select
items={agentItems}
value={agent}
onValueChange={(value: string | null) => value !== null && onAgentChange(value)}
>
<SelectTrigger size="sm" className="h-7 min-w-[130px] text-[12px]" aria-label="Filter by agent">
<SelectValue />
</SelectTrigger>
<SelectContent>
{agentItems.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Select
items={STATUS_ITEMS}
value={status}
onValueChange={(value: RunStatusFilter | null) => value !== null && onStatusChange(value)}
>
<SelectTrigger size="sm" className="h-7 min-w-[110px] text-[12px]" aria-label="Filter by status">
<SelectValue />
</SelectTrigger>
<SelectContent>
{STATUS_ITEMS.map((item) => (
<SelectItem key={item.value} value={item.value}>
{item.label}
</SelectItem>
))}
</SelectContent>
</Select>
{children && <div className="ml-auto flex items-center gap-2">{children}</div>}
</div>
);
}

View file

@ -125,7 +125,7 @@ function HoverCardBody({ facts, traceStartMs }: { facts: HoverFacts; traceStartM
</span>
)}
</div>
<span className="text-[12px] text-trace-duration">
<span className="text-xs text-trace-duration">
{TYPE_LABEL[facts.type]}
{facts.model ? ` · ${facts.model}` : ""}
</span>
@ -150,7 +150,7 @@ function HoverCardBody({ facts, traceStartMs }: { facts: HoverFacts; traceStartM
<FactSection title="Tags">
<ul className="flex flex-wrap gap-1">
{facts.tags.map((tag) => (
<li key={tag} className="rounded-[3px] bg-trace-tag px-1 py-0.5 text-trace-duration">
<li key={tag} className="rounded-sm bg-trace-tag px-1 py-0.5 text-trace-duration">
{tag}
</li>
))}
@ -158,7 +158,7 @@ function HoverCardBody({ facts, traceStartMs }: { facts: HoverFacts; traceStartM
</FactSection>
)}
{facts.error && (
<div className="rounded-[4px] bg-destructive/10 px-2 py-1.5 font-mono text-[12px] break-words text-destructive">
<div className="rounded-sm bg-destructive/10 px-2 py-1.5 font-mono text-xs break-words text-destructive">
{errorHeadline(facts.error)}
</div>
)}
@ -183,7 +183,7 @@ export function SpanHoverCard({ facts, traceStartMs, children }: SpanHoverCardPr
sideOffset={0}
alignOffset={0}
data-testid="span-hover-card"
className="max-h-[min(520px,calc(100vh-16px))] w-auto max-w-[420px] overflow-y-auto rounded-[6px] border border-trace-border bg-trace-surface p-3 text-[13px] leading-[1.2] tracking-[-0.26px] text-trace-text shadow-trace-md ring-0 transition-opacity duration-150 ease-[cubic-bezier(0,0,0.2,1)] data-closed:animate-none data-ending-style:opacity-0 data-open:animate-none motion-reduce:transition-none"
className="max-h-[min(520px,calc(100vh-16px))] w-auto max-w-[420px] overflow-y-auto rounded-md border border-trace-border bg-trace-surface p-3 text-sm leading-tight text-trace-text shadow-trace-md ring-0 transition-opacity duration-150 ease-[cubic-bezier(0,0,0.2,1)] data-closed:animate-none data-ending-style:opacity-0 data-open:animate-none motion-reduce:transition-none"
>
<HoverCardBody facts={facts} traceStartMs={traceStartMs} />
</HoverCardContent>

View file

@ -21,8 +21,8 @@ import { useSpanProvider } from "./spanProvider";
import type { SpanType } from "./traceTypes";
const SIZE = {
sm: "size-4 rounded-[2px]",
card: "size-[18px] rounded-[3px]",
sm: "size-4 rounded-sm",
card: "size-[18px] rounded-sm",
md: "size-5 rounded-full",
lg: "size-5 rounded-full",
} as const;

View file

@ -16,6 +16,7 @@ import {
import { cn } from "@/lib/cva.config";
import { FoldChevron } from "./Collapse";
import { PaneBar } from "./PaneBar";
import { groupFacts, SpanHoverCard, spanFacts } from "./SpanHoverCard";
import { SpanIcon } from "./SpanIcon";
import type { GroupRowData, SpanRowData, TreeRow } from "./traceTree";
@ -50,9 +51,9 @@ const rowClass = (selected: boolean): string =>
selected ? "border-l-primary bg-accent" : "border-l-transparent hover:bg-muted/60",
);
const LINE = "border-dashed border-border [border-width:0] [border-left-width:1px]";
const ELBOW = "border-dashed border-border [border-width:0] [border-left-width:1px] [border-bottom-width:1px]";
const NAME = "line-clamp-2 break-words text-[13px] leading-[1.2] font-medium";
const LINE = "border-l border-dashed border-border";
const ELBOW = "border-b border-l border-dashed border-border";
const NAME = "line-clamp-2 break-words text-sm leading-tight font-medium";
const META = "text-xs leading-4 text-muted-foreground tabular-nums";
/** One 20px column per ancestor: pass-through rails for open branches, an elbow into this row's tile. */
@ -99,7 +100,7 @@ function Caret({ open, onToggle, label }: { open: boolean; onToggle: () => void;
role="button"
tabIndex={-1}
aria-label={label}
className="grid size-6 shrink-0 place-items-center self-start rounded-[4px] p-0.5 text-muted-foreground transition-colors duration-150 hover:bg-muted motion-reduce:transition-none"
className="grid size-6 shrink-0 place-items-center self-start rounded-sm p-0.5 text-muted-foreground transition-colors duration-150 hover:bg-muted motion-reduce:transition-none"
onClick={(event) => {
event.stopPropagation();
onToggle();
@ -295,79 +296,78 @@ export function SpanTree({
return (
<section className="flex h-full min-h-0 min-w-0 flex-col border-r border-border bg-card" aria-label="Run spans">
<div className="shrink-0 space-y-3 border-b px-3 py-3">
<div className="flex items-center justify-between gap-2">
<span className="text-sm font-medium">
Steps{" "}
<span className="ml-1 text-xs font-normal text-muted-foreground">
{summary.span_count.toLocaleString()}
</span>
<PaneBar className="justify-between">
<span className="text-sm font-medium">
Steps{" "}
<span className="ml-1 text-xs font-normal text-muted-foreground">
{filtering && `${rows.length.toLocaleString()} of `}
{summary.span_count.toLocaleString()}
</span>
<div className="flex items-center gap-1">
{onOpenDetails && (
<Button variant="ghost" size="icon-xs" onClick={onOpenDetails} aria-label="Show details">
<PanelRightOpen className="size-4" />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-xs" aria-label="Step display options" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuCheckboxItem checked={hideFramework} onCheckedChange={onToggleHideFramework}>
Hide framework spans
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onCollapseAll}>Collapse branches</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</span>
<div className="flex items-center gap-1">
{onOpenDetails && (
<Button variant="ghost" size="icon-xs" onClick={onOpenDetails} aria-label="Show details">
<PanelRightOpen className="size-4" />
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger render={<Button variant="ghost" size="icon-xs" aria-label="Step display options" />}>
<MoreHorizontal className="size-4" />
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-52">
<DropdownMenuCheckboxItem checked={hideFramework} onCheckedChange={onToggleHideFramework}>
Hide framework spans
</DropdownMenuCheckboxItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={onCollapseAll}>Collapse branches</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
<div className="relative">
<Search className="pointer-events-none absolute top-2.5 left-2.5 size-3.5 text-muted-foreground" />
</PaneBar>
<PaneBar>
<div className="relative min-w-0 flex-1">
<Search className="pointer-events-none absolute top-1/2 left-2 size-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
aria-label="Search steps"
placeholder="Search steps"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
className="h-8 pr-8 pl-8 text-xs shadow-none md:text-xs"
className="h-7 pr-7 pl-7 text-xs shadow-none md:text-xs"
/>
{query && (
<Button
variant="ghost"
size="icon-xs"
aria-label="Clear step search"
className="absolute top-1 right-1"
className="absolute top-1/2 right-0.5 -translate-y-1/2"
onClick={() => onQueryChange("")}
>
<X className="size-3" />
</Button>
)}
</div>
<div className="flex items-center gap-1">
<Button
variant={!errorsOnly ? "secondary" : "ghost"}
size="xs"
aria-pressed={!errorsOnly}
onClick={() => onErrorsOnlyChange(false)}
>
All steps
</Button>
<Button
variant={errorsOnly ? "secondary" : "ghost"}
size="xs"
aria-pressed={errorsOnly}
onClick={() => onErrorsOnlyChange(true)}
>
Errors <span className="ml-1 text-muted-foreground">{summary.error_count}</span>
</Button>
{filtering && (
<span className="ml-auto text-xs text-muted-foreground" role="status">
{rows.length} found
</span>
)}
</div>
</div>
<Button
variant={!errorsOnly ? "secondary" : "ghost"}
size="xs"
aria-pressed={!errorsOnly}
onClick={() => onErrorsOnlyChange(false)}
>
All
</Button>
<Button
variant={errorsOnly ? "secondary" : "ghost"}
size="xs"
aria-pressed={errorsOnly}
onClick={() => onErrorsOnlyChange(true)}
>
Errors <span className="ml-1 text-muted-foreground">{summary.error_count}</span>
</Button>
{filtering && (
<span role="status" className="sr-only">
{rows.length} found
</span>
)}
</PaneBar>
<div ref={scrollRef} className="min-h-0 flex-1 overflow-auto py-2">
{rows.length === 0 && (
<div className="px-4 py-8 text-center text-sm text-muted-foreground">
@ -385,7 +385,7 @@ export function SpanTree({
key={row.id}
type="button"
onClick={() => onLoadMore(row.groupId)}
className={cn(rowClass(false), "h-8 text-[13px] tracking-[-0.26px] text-muted-foreground")}
className={cn(rowClass(false), "h-8 text-sm text-muted-foreground")}
>
<Gutters depth={row.depth} guide={guides[i]} />
<span className="ml-0.5 flex items-center gap-2">
@ -413,7 +413,7 @@ export function SpanTree({
})}
</div>
</div>
<div className="flex min-h-8 shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-t border-border px-3 py-2 text-[10px] text-muted-foreground">
<div className="flex min-h-10 shrink-0 flex-wrap items-center gap-x-3 gap-y-1 border-t border-border px-3 py-1.5 text-xs text-muted-foreground">
{embedded ? (
<>
<span className="whitespace-nowrap">
@ -441,7 +441,7 @@ export function SpanTree({
function Kbd({ children }: { children: React.ReactNode }) {
return (
<kbd className="rounded-[3px] border border-border border-b-2 bg-muted px-[3px] font-mono text-muted-foreground">
<kbd className="rounded-sm border border-border border-b-2 bg-muted px-[3px] font-mono text-muted-foreground">
{children}
</kbd>
);

View file

@ -11,7 +11,7 @@ export function StatusMark({ status, count, subtle = false }: StatusMarkProps) {
if (status === "error") {
return (
<span
className="inline-flex items-center gap-1 text-[11px] text-destructive"
className="inline-flex items-center gap-1 text-xs text-destructive"
aria-label={`${count ?? 1} error${count === 1 ? "" : "s"}`}
>
<AlertCircle className="size-3" />

View file

@ -27,7 +27,7 @@ const RANGE_LABEL_FORMAT = "MMM D, h:mm A";
export const rangeLabel = (range: TimeWindow): string =>
`${moment(range.startMs).format(RANGE_LABEL_FORMAT)} to ${moment(range.endMs).format(RANGE_LABEL_FORMAT)}`;
const SEGMENT = "inline-flex h-7 items-center gap-1.5 px-2.5 text-[13px] outline-none focus-visible:bg-accent";
const SEGMENT = "inline-flex h-7 items-center gap-1.5 px-2.5 text-sm outline-none focus-visible:bg-accent";
interface TimeRangeControlsProps {
range: TimeWindow;
@ -79,7 +79,7 @@ export function TimeRangeControls({
onValueChange={(value: string) => onRangeHoursChange(Number(value))}
>
{RANGE_PRESETS.map((preset) => (
<DropdownMenuRadioItem key={preset.hours} value={String(preset.hours)} className="text-[13px]">
<DropdownMenuRadioItem key={preset.hours} value={String(preset.hours)} className="text-sm">
{preset.label}
</DropdownMenuRadioItem>
))}

View file

@ -135,6 +135,36 @@ describe("RunView", () => {
expect(screen.queryByRole("button", { name: "Back to runs" })).not.toBeInTheDocument();
});
it("keeps the current run on screen, inert, while an unvisited run loads in the drawer", async () => {
const user = userEvent.setup();
let resolveSwarm: (trace: Trace) => void = () => {};
vi.mocked(agentTraceCall).mockImplementation((_token, traceId) =>
traceId === swarm.summary.trace_id
? new Promise<Trace>((resolve) => {
resolveSwarm = resolve;
})
: Promise.resolve(research),
);
const props = { accessToken: "sk-test", onBack: vi.fn(), embedded: true };
const { rerender } = renderWithProviders(<RoutedRunView traceId={research.summary.trace_id} {...props} />);
const root = rootSpanId(research);
expect(await screen.findByTestId("detail-pane")).toHaveAttribute("data-row-id", root);
rerender(<RoutedRunView traceId={swarm.summary.trace_id} {...props} />);
await waitFor(() => expect(screen.getByTestId("run-view")).toHaveAttribute("aria-busy", "true"));
expect(screen.queryByRole("status", { name: "Loading trace" })).not.toBeInTheDocument();
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary));
await user.keyboard("{ArrowDown}");
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", root);
act(() => resolveSwarm(swarm));
await waitFor(() =>
expect(screen.getByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(swarm.summary)),
);
expect(screen.getByTestId("run-view")).toHaveAttribute("aria-busy", "false");
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", initialRunSelection(swarm).selectedId);
});
it("moves the selection with J / K and closes the detail pane with Esc", async () => {
const user = userEvent.setup();
renderRun(research);
@ -297,6 +327,15 @@ describe("RunView", () => {
expect(onBack).toHaveBeenCalledTimes(1);
});
it("loads the run on retry after a failed load", async () => {
const user = userEvent.setup();
vi.mocked(agentTraceCall).mockRejectedValueOnce(new Error("Traces are temporarily unavailable"));
renderRun(research);
await user.click(await screen.findByRole("button", { name: "Retry" }));
expect(await screen.findByRole("heading", { level: 1 })).toHaveTextContent(traceDisplayName(research.summary));
});
it("finds a step beyond a folded group's first page and reveals it after search clears", async () => {
const user = userEvent.setup();
const root = research.spans.find((span) => span.parent_span_id === null)!;
@ -342,12 +381,14 @@ describe("RunView", () => {
});
it("does not navigate steps while typing or moving the search cursor", async () => {
const user = userEvent.setup();
renderRun(research);
const search = await screen.findByRole("textbox", { name: "Search steps" });
const selected = screen.getByTestId("detail-pane").getAttribute("data-row-id");
await user.type(search, "jk{ArrowDown}{ArrowUp}");
fireEvent.change(search, { target: { value: "jk" } });
expect(search).toHaveValue("jk");
for (const key of ["j", "k", "ArrowDown", "ArrowUp"]) {
fireEvent.keyDown(search, { key });
}
expect(screen.getByTestId("detail-pane")).toHaveAttribute("data-row-id", selected);
});

View file

@ -1,9 +1,11 @@
"use client";
import { type TraceHandoff, useTracesApi } from "./tracesApi";
import { useInfiniteQuery, useQueryClient } from "@tanstack/react-query";
import { QueryErrorResetBoundary, useQueryClient, useSuspenseInfiniteQuery } from "@tanstack/react-query";
import { ArrowLeft, Check, Copy } from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { Suspense, useCallback, useDeferredValue, useEffect, useMemo, useState } from "react";
import { ErrorBoundary } from "react-error-boundary";
import { useEventListener, useTimeout } from "usehooks-ts";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
@ -19,7 +21,7 @@ import { restartsTraversal } from "./readFailure";
import { SpanTree } from "./SpanTree";
import { TraceConversation } from "./TraceConversation";
import { FrameworkLogo, traceFramework } from "./TraceFramework";
import type { RunSelection } from "./traceRouting";
import { type RunSelection, traceKey } from "./traceRouting";
import type { SpanTreeState, TreeRow } from "./traceTree";
import type { Trace } from "./traceTypes";
import {
@ -81,16 +83,12 @@ const toggle = (set: ReadonlySet<string>, id: string): Set<string> => {
function CopyForAgent({ handoff }: { handoff: TraceHandoff }) {
const [copied, setCopied] = useState(false);
useEffect(() => {
if (!copied) return;
const timeout = window.setTimeout(() => setCopied(false), 1600);
return () => window.clearTimeout(timeout);
}, [copied]);
useTimeout(() => setCopied(false), copied ? 1600 : null);
return (
<Button
variant="outline"
size="xs"
className="h-7 shrink-0 gap-1.5 rounded-md text-[12px] shadow-none"
className="h-7 shrink-0 gap-1.5 rounded-md text-xs shadow-none"
onClick={async () => setCopied(await copyToClipboard(handoff.text, handoff.copied))}
>
{copied ? <Check className="size-3" /> : <Copy className="size-3" />}
@ -193,9 +191,10 @@ interface RunBodyProps {
accessToken: string;
selection: RunSelection;
embedded: boolean;
stale: boolean;
}
function RunBody({ trace, accessToken, selection, embedded }: RunBodyProps) {
function RunBody({ trace, accessToken, selection, embedded, stale }: RunBodyProps) {
const spanKeys = embedded ? EMBEDDED_SPAN_KEYS : SPAN_KEYS;
const { view, selectSpan, setView, stepQuery: query, setStepQuery: setQuery, errorsOnly, setErrorsOnly } = selection;
const [initial] = useState(() => initialRunSelection(trace, selection.spanId ?? undefined));
@ -250,14 +249,14 @@ function RunBody({ trace, accessToken, selection, embedded }: RunBodyProps) {
[],
);
useEffect(() => {
if (view !== "steps") return;
const setRowExpanded = (row: TreeRow, expand: boolean) => {
if (row.kind === "span" && row.hasChildren && row.collapsed === expand) toggleSpan(row.id);
if (row.kind === "group" && row.expanded !== expand) toggleGroup(row.id);
};
const onKeyDown = (event: KeyboardEvent) => {
if (ignoreStepKey(event)) return;
const setRowExpanded = (row: TreeRow, expand: boolean) => {
if (row.kind === "span" && row.hasChildren && row.collapsed === expand) toggleSpan(row.id);
if (row.kind === "group" && row.expanded !== expand) toggleGroup(row.id);
};
useEventListener(
"keydown",
(event) => {
if (stale || view !== "steps" || ignoreStepKey(event)) return;
const index = rows.findIndex((row) => row.id === selectedRow?.id);
const row = rows[index];
if (event.key === "Escape" && detailOpen) {
@ -279,10 +278,10 @@ function RunBody({ trace, accessToken, selection, embedded }: RunBodyProps) {
} else if (event.key === "ArrowRight" && row) {
setRowExpanded(row, true);
}
};
window.addEventListener("keydown", onKeyDown, true);
return () => window.removeEventListener("keydown", onKeyDown, true);
}, [rows, selectedRow, detailOpen, select, toggleSpan, toggleGroup, spanKeys, view]);
},
undefined,
true,
);
if (view === "conversation")
return (
@ -365,8 +364,8 @@ interface RunViewProps {
embedded?: boolean;
}
function selectedSpanMissing(trace: Trace | undefined, spanId: string | null): boolean {
return Boolean(spanId && trace && !trace.spans.some((span) => span.span_id === spanId));
function selectedSpanMissing(trace: Trace, spanId: string | null): boolean {
return Boolean(spanId && !trace.spans.some((span) => span.span_id === spanId));
}
const loadMoreStatus = (trace: Trace, failed: boolean, mustRestart: boolean): string => {
@ -375,11 +374,82 @@ const loadMoreStatus = (trace: Trace, failed: boolean, mustRestart: boolean): st
return `Showing ${trace.spans.length.toLocaleString()} of ${trace.summary.span_count.toLocaleString()} steps`;
};
export function RunView({ traceId, traceRef, selection, accessToken, onBack, embedded = false }: RunViewProps) {
function RunLoading({ embedded }: { embedded: boolean }) {
return (
<div
role="status"
aria-label="Loading trace"
className={embedded ? "flex flex-col gap-3 p-4" : "flex h-[60vh] items-center justify-center"}
>
{embedded ? (
[72, 48, 88, 60, 80].map((w) => (
<div key={w} className="h-4 animate-pulse rounded bg-trace-row-hover" style={{ width: `${w}%` }} />
))
) : (
<UiLoadingSpinner className="size-6 text-muted-foreground" />
)}
</div>
);
}
function RunLoadError({ error, onBack, onRetry }: { error: unknown; onBack: () => void; onRetry: () => void }) {
return (
<div className="p-6 text-xs" data-testid="run-view-error">
<button
type="button"
onClick={onBack}
className="mb-4 inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3.5" /> Back to traces
</button>
<h1 className="mb-2 text-sm font-medium">Could not load trace</h1>
<span className="text-muted-foreground">{error instanceof Error ? error.message : "Unknown error"}</span>
<Button variant="outline" size="sm" className="ml-3" onClick={onRetry}>
Retry
</Button>
</div>
);
}
/** Keeps the shown run on screen, inert, while the next one loads. */
export function RunView(props: RunViewProps) {
const traceId = useDeferredValue(props.traceId);
const traceRef = useDeferredValue(props.traceRef);
const switching = traceId !== props.traceId || traceRef !== props.traceRef;
const shownKey = traceKey({ traceId, traceRef });
return (
<QueryErrorResetBoundary>
{({ reset }) => (
<ErrorBoundary
onReset={reset}
resetKeys={[shownKey]}
fallbackRender={({ error, resetErrorBoundary }) => (
<RunLoadError error={error} onBack={props.onBack} onRetry={resetErrorBoundary} />
)}
>
<Suspense fallback={<RunLoading embedded={props.embedded ?? false} />}>
<LoadedRun key={shownKey} {...props} traceId={traceId} traceRef={traceRef} switching={switching} />
</Suspense>
</ErrorBoundary>
)}
</QueryErrorResetBoundary>
);
}
function LoadedRun({
traceId,
traceRef,
selection,
accessToken,
onBack,
embedded = false,
switching,
}: RunViewProps & { switching: boolean }) {
const traces = useTracesApi(accessToken);
const queryClient = useQueryClient();
const queryKey = ["agentTrace", traceId, traceRef, accessToken];
const traceQueryOptions = {
queryKey: ["agentTrace", traceId, traceRef, accessToken],
queryKey,
queryFn: ({ pageParam }: { pageParam: string | null }) => traces.trace(traceId, traceRef, pageParam),
initialPageParam: null as string | null,
getNextPageParam: (lastPage: Trace) => lastPage.next_cursor ?? undefined,
@ -389,14 +459,13 @@ export function RunView({ traceId, traceRef, selection, accessToken, onBack, emb
refetchOnMount: false,
retry: false,
};
const traceQuery = useInfiniteQuery(traceQueryOptions);
const refreshTrace = () => queryClient.resetQueries({ queryKey: traceQueryOptions.queryKey, exact: true });
const traceQuery = useSuspenseInfiniteQuery(traceQueryOptions);
const refreshTrace = () => queryClient.resetQueries({ queryKey, exact: true });
const trace = useMemo(() => {
const pages = traceQuery.data?.pages;
if (!pages?.length) return undefined;
return { ...pages[0], spans: pages.flatMap((page) => page.spans) };
const [first, ...rest] = traceQuery.data.pages;
return { ...first, spans: [first, ...rest].flatMap((page) => page.spans) };
}, [traceQuery.data]);
const seekingSpan = selectedSpanMissing(trace, selection.spanId);
const seekingSpan = !switching && selectedSpanMissing(trace, selection.spanId);
const { hasNextPage, isFetching, isError, fetchNextPage } = traceQuery;
const canSeek = seekingSpan && hasNextPage;
useEffect(() => {
@ -405,41 +474,6 @@ export function RunView({ traceId, traceRef, selection, accessToken, onBack, emb
const mustRestart = restartsTraversal(traceQuery.error);
const pageAction = isError ? "Retry" : "Load more steps";
if (traceQuery.isLoading) {
return (
<div
role="status"
aria-label="Loading trace"
className={embedded ? "flex flex-col gap-3 p-4" : "flex h-[60vh] items-center justify-center"}
>
{embedded ? (
[72, 48, 88, 60, 80].map((w) => (
<div key={w} className="h-4 animate-pulse rounded bg-trace-row-hover" style={{ width: `${w}%` }} />
))
) : (
<UiLoadingSpinner className="size-6 text-muted-foreground" />
)}
</div>
);
}
if (!trace) {
return (
<div className="p-6 text-[12px]" data-testid="run-view-error">
<button
type="button"
onClick={onBack}
className="mb-4 inline-flex items-center gap-1.5 text-[12px] text-muted-foreground hover:text-foreground"
>
<ArrowLeft className="size-3.5" /> Back to traces
</button>
<h1 className="mb-2 text-[13px] font-medium">Could not load trace</h1>
<span className="text-muted-foreground">{traceQuery.error?.message ?? "Unknown error"}</span>
<Button variant="outline" size="sm" className="ml-3" onClick={() => void refreshTrace()}>
Retry
</Button>
</div>
);
}
return (
<Tabs
value={selection.view}
@ -447,7 +481,10 @@ export function RunView({ traceId, traceRef, selection, accessToken, onBack, emb
className={cn(
"@container/trace flex flex-1 flex-col gap-0 overflow-hidden bg-background",
embedded ? "min-h-0" : "min-h-[560px] border-y border-border",
switching && "opacity-60 transition-opacity delay-150 duration-150 motion-reduce:transition-none",
)}
aria-busy={switching}
inert={switching}
data-testid="run-view"
>
<RunHeader
@ -487,6 +524,7 @@ export function RunView({ traceId, traceRef, selection, accessToken, onBack, emb
accessToken={accessToken}
selection={selection}
embedded={embedded}
stale={switching}
/>
</Tabs>
);

View file

@ -1,11 +1,13 @@
"use client";
import { X } from "lucide-react";
import moment from "moment";
import { useEffect, useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState, type RefObject } from "react";
import { useMediaQuery, useResizeObserver } from "usehooks-ts";
import { cn } from "@/lib/cva.config";
import { FIELD_COLS, FIELD_ROWS, agentDotColor, agoLabel, columnDots, isReceiving } from "./lensField";
import { FIELD_COLS, FIELD_ROWS, columnDots } from "./lensField";
import type { TraceSummary } from "./traceTypes";
import { traceAgentNames } from "./traceUtils";
@ -136,9 +138,10 @@ function drawField(canvas: HTMLCanvasElement, { buckets, max, band, hover, progr
}
function useRiseIn(): number {
const reduceMotion = useMediaQuery("(prefers-reduced-motion: reduce)");
const [progress, setProgress] = useState(0);
useEffect(() => {
if (typeof window.matchMedia !== "function" || window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
if (reduceMotion) {
setProgress(1);
return;
}
@ -149,7 +152,7 @@ function useRiseIn(): number {
if (t < 1) frame = requestAnimationFrame(rise);
});
return () => cancelAnimationFrame(frame);
}, []);
}, [reduceMotion]);
return progress;
}
@ -166,17 +169,13 @@ function DotField({
}) {
const canvas = useRef<HTMLCanvasElement>(null);
const progress = useRiseIn();
const { width, height } = useResizeObserver({ ref: canvas as RefObject<HTMLCanvasElement> });
useEffect(() => {
const node = canvas.current;
if (!node) return;
const frame: FieldFrame = { buckets, max, band, hover, progress };
const paint = () => drawField(node, frame);
paint();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(paint);
observer.observe(node);
return () => observer.disconnect();
}, [buckets, max, band, hover, progress]);
if (canvas.current) {
const fieldFrame = { buckets, max, band, hover, progress };
drawField(canvas.current, fieldFrame);
}
}, [buckets, max, band, hover, progress, width, height]);
return (
<canvas
ref={canvas}
@ -191,74 +190,6 @@ function BucketBar({ bucket }: { bucket: Bucket }) {
return <div className="pointer-events-none h-full flex-1" data-testid="timeline-bucket" data-runs={bucket.runs} />;
}
function useNow(intervalMs: number): number {
const [now, setNow] = useState(Date.now);
useEffect(() => {
const timer = window.setInterval(() => setNow(Date.now()), intervalMs);
return () => window.clearInterval(timer);
}, [intervalMs]);
return now;
}
function LivePulse({ live }: { live: boolean }) {
return (
<span aria-hidden="true" className="relative flex size-2">
{live && (
<span className="absolute inline-flex size-full rounded-full bg-[#3b5bfd] opacity-60 motion-safe:animate-ping" />
)}
<span
className={cn("relative inline-flex size-2 rounded-full", live ? "bg-[#3b5bfd]" : "bg-muted-foreground/50")}
/>
</span>
);
}
function latestStart(runs: readonly TraceSummary[]): number | null {
return runs.reduce<number | null>((latest, run) => {
const t = moment(run.start_time).valueOf();
return latest === null || t > latest ? t : latest;
}, null);
}
function FieldHeader({ runs, range }: { runs: readonly TraceSummary[]; range: TimeWindow }) {
const now = useNow(1000);
const agents = Array.from(new Set(runs.flatMap(traceAgentNames)));
const failed = runs.filter((run) => run.error_count > 0).length;
const lastMs = latestStart(runs);
const live = isReceiving(lastMs, now);
return (
<div className="flex flex-wrap items-center justify-between gap-x-6 gap-y-1 font-mono text-[11px] leading-none">
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-muted-foreground tabular-nums">
<span className="flex items-center gap-2" role="status" data-testid="traces-last-seen">
<LivePulse live={live} />
<span className={cn("font-semibold", live ? "text-[#0011b3] dark:text-[#8b9bff]" : "text-foreground")}>
{live ? "receiving" : "idle"}
</span>
{lastMs !== null && <span>last trace {agoLabel(lastMs, now)}</span>}
</span>
<span>
<span className="font-semibold text-foreground">{runs.length.toLocaleString()}</span>{" "}
{runs.length === 1 ? "run" : "runs"} from{" "}
<span className="font-semibold text-foreground">{agents.length}</span>{" "}
{agents.length === 1 ? "agent" : "agents"}
{failed > 0 && <span className="text-[#e5484d]"> · {failed} failed</span>}
</span>
</div>
<div className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 text-muted-foreground">
{agents.slice(0, 6).map((agent) => (
<span key={agent} className="flex items-center gap-1.5">
<span className="size-[6px] rounded-full" style={{ backgroundColor: agentDotColor(agent) }} />
{agent}
</span>
))}
<span className="rounded border border-border bg-muted/50 px-1.5 py-px">
Total {formatSpan(range.endMs - range.startMs)}
</span>
</div>
</div>
);
}
function NowEdge() {
return (
<>
@ -277,7 +208,7 @@ function NowEdge() {
function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
return (
<div
className="pointer-events-none absolute top-full z-floating mt-1 rounded-md border border-border bg-popover px-2.5 py-1.5 font-mono text-[11px] text-popover-foreground shadow-md"
className="pointer-events-none absolute top-full z-floating mt-1 rounded-md border border-border bg-popover px-2.5 py-1.5 font-mono text-xs text-popover-foreground shadow-md"
style={{ left: pct(Math.min(0.8, index / BUCKETS)) }}
role="tooltip"
>
@ -288,7 +219,7 @@ function BucketTooltip({ bucket, index }: { bucket: Bucket; index: number }) {
{bucket.runs} {bucket.runs === 1 ? "run" : "runs"}
{bucket.failed > 0 && `, ${bucket.failed} failed`}
</div>
<div className="text-[#0011b3] dark:text-[#8b9bff]">drag to zoom</div>
<div className="text-info">drag to zoom</div>
</div>
);
}
@ -351,8 +282,7 @@ function SelectionBracket({
const handle = "absolute inset-y-0 w-[2px] cursor-ew-resize bg-[#0011b3] dark:bg-[#8b9bff]";
const tick =
"before:absolute before:top-0 before:h-[2px] before:w-2 before:bg-inherit after:absolute after:bottom-0 after:h-[2px] after:w-2 after:bg-inherit";
const edgeLabel =
"pointer-events-none absolute -bottom-5 font-mono text-[10px] whitespace-nowrap text-[#0011b3] tabular-nums dark:text-[#8b9bff]";
const edgeLabel = "pointer-events-none absolute -bottom-5 font-mono text-xs whitespace-nowrap text-info tabular-nums";
return (
<>
<div
@ -372,7 +302,7 @@ function SelectionBracket({
onPointerDown={onHandleDown("resize-hi")}
/>
{widthPx >= MIN_DURATION_LABEL_PX && (
<span className="pointer-events-none absolute inset-x-0 top-2 text-center font-mono text-[11px] font-semibold text-[#0011b3] dark:text-[#8b9bff]">
<span className="pointer-events-none absolute inset-x-0 top-2 text-center font-mono text-xs font-semibold text-info">
{formatSpan(window.endMs - window.startMs)}
</span>
)}
@ -404,7 +334,7 @@ function TickAxis({ range }: { range: TimeWindow }) {
style={{ left: pct(t), transform: tickShift(t) }}
>
<span className="h-1 w-px bg-border" />
<span className="font-mono text-[10px] whitespace-nowrap text-muted-foreground tabular-nums">
<span className="font-mono text-xs whitespace-nowrap text-muted-foreground tabular-nums">
{moment(range.startMs + (range.endMs - range.startMs) * t).format(format)}
</span>
</div>
@ -421,18 +351,10 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
const [drag, setDrag] = useState<DragState | null>(null);
const [draft, setDraft] = useState<Band | null>(null);
const areaRef = useRef<HTMLDivElement>(null);
const [stripWidth, setStripWidth] = useState(0);
useEffect(() => {
const el = areaRef.current;
if (!el) return;
const measure = () => setStripWidth(el.getBoundingClientRect().width);
measure();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(measure);
observer.observe(el);
return () => observer.disconnect();
}, []);
const { width: stripWidth = 0 } = useResizeObserver({
ref: areaRef as RefObject<HTMLDivElement>,
box: "border-box",
});
const committed = bandForWindow(buckets, selection);
const band = drag ? draft : committed;
@ -486,12 +408,11 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
tabIndex={0}
onKeyDown={onKeyDown}
>
<FieldHeader runs={runs} range={range} />
<div
ref={areaRef}
role="presentation"
data-testid="timeline-area"
className="relative mt-2 flex cursor-crosshair touch-none items-end"
className="relative flex cursor-crosshair touch-none items-end"
style={{ height: FIELD_HEIGHT }}
onPointerDown={(e) => begin("select", e)}
onPointerMove={onMove}
@ -525,6 +446,16 @@ export function TracesTimeline({ runs, range, selection, onSelect }: TracesTimel
<TickAxis range={range} />
</div>
{hover !== null && !drag && <BucketTooltip bucket={buckets[hover]} index={hover} />}
{selection && (
<button
type="button"
onClick={() => onSelect(null)}
aria-label="Clear time zoom"
className="absolute top-1 right-3 inline-flex size-5 items-center justify-center rounded text-muted-foreground hover:bg-muted hover:text-foreground"
>
<X className="size-3" />
</button>
)}
</div>
);
}

View file

@ -2,6 +2,7 @@
import { ArrowRight, ArrowUpRight, Check, Copy, KeyRound, Loader2, Send } from "lucide-react";
import { useState } from "react";
import { useTimeout } from "usehooks-ts";
import { cn } from "@/lib/cva.config";
import { LensPreviewButton } from "@/components/lens/LensPreviewButton";
@ -99,12 +100,8 @@ function CodeBlock({
wrap?: boolean;
}) {
const [copied, setCopied] = useState(false);
const copy = async () => {
if (await copyToClipboard(code)) {
setCopied(true);
window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
}
};
useTimeout(() => setCopied(false), copied ? COPIED_RESET_MS : null);
const copy = async () => setCopied(await copyToClipboard(code));
return (
<div className="overflow-hidden rounded-md border border-border bg-muted/30">
<div className="flex h-9 items-center border-b border-border px-3">
@ -131,7 +128,7 @@ function CodeBlock({
}
function FileLabel({ children }: { children: React.ReactNode }) {
return <span className="text-[12.5px] text-foreground">{children}</span>;
return <span className="text-xs text-foreground">{children}</span>;
}
function LineTabs<T extends string>({
@ -155,7 +152,7 @@ function LineTabs<T extends string>({
aria-selected={value === option}
onClick={() => onChange(option)}
className={cn(
"-mb-px inline-flex h-9 items-center gap-1.5 border-b-2 text-[12.5px]",
"-mb-px inline-flex h-9 items-center gap-1.5 border-b-2 text-xs",
value === option
? "border-foreground text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground",
@ -351,18 +348,14 @@ function TracingKey({
function EndpointValue({ value }: { value: string }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
if (await copyToClipboard(value)) {
setCopied(true);
window.setTimeout(() => setCopied(false), COPIED_RESET_MS);
}
};
useTimeout(() => setCopied(false), copied ? COPIED_RESET_MS : null);
const copy = async () => setCopied(await copyToClipboard(value));
return (
<button
type="button"
onClick={() => void copy()}
aria-label={`Copy ${value}`}
className="group inline-flex min-w-0 items-center gap-2 text-left font-mono text-[12.5px] text-foreground"
className="group inline-flex min-w-0 items-center gap-2 text-left font-mono text-xs text-foreground"
>
<span className="break-all">{value}</span>
{copied ? (
@ -387,9 +380,9 @@ function Endpoints({ proxyUrl, children }: { proxyUrl: string; children?: React.
<dl className="mt-3 grid gap-x-6 gap-y-3 rounded-md border bg-muted/30 p-4 md:grid-cols-2">
{otlpEndpoints(proxyUrl).map(([label, value, copyable]) => (
<div key={label} className={cn("min-w-0", label === "Traces endpoint" && "md:col-span-2")}>
<dt className="text-[12px] text-muted-foreground">{label}</dt>
<dt className="text-xs text-muted-foreground">{label}</dt>
<dd className="mt-0.5">
{copyable ? <EndpointValue value={value} /> : <span className="text-[12.5px]">{value}</span>}
{copyable ? <EndpointValue value={value} /> : <span className="text-xs">{value}</span>}
</dd>
</div>
))}
@ -456,11 +449,9 @@ function CodingAgentSetup({ proxyUrl, guide, model }: { proxyUrl: string; guide:
const [codingAgent, setCodingAgent] = useState<CodingAgent>("Claude Code");
const [copied, setCopied] = useState<string | null>(null);
const command = codingAgentCommand(codingAgent, codingAgentPrompt(proxyUrl, guide, model));
useTimeout(() => setCopied(null), copied === null ? null : COPIED_RESET_MS);
const copy = async () => {
if (await copyToClipboard(command)) {
setCopied(command);
window.setTimeout(() => setCopied(null), COPIED_RESET_MS);
}
if (await copyToClipboard(command)) setCopied(command);
};
return (
<section className="mt-6" aria-labelledby="connect-project">

View file

@ -6,7 +6,6 @@ import {
FIELD_ROWS,
UNNAMED_AGENT_COLOR,
agoLabel,
isReceiving,
agentDotColor,
columnDots,
columnTop,
@ -102,13 +101,3 @@ describe("agoLabel", () => {
expect(agoLabel(now + 10_000, now)).toBe("just now");
});
});
describe("isReceiving", () => {
const now = Date.UTC(2026, 9, 3, 12, 0, 0);
it("is live only when a trace landed in the last five minutes", () => {
expect(isReceiving(now - 4 * 60_000, now)).toBe(true);
expect(isReceiving(now - 6 * 60_000, now)).toBe(false);
expect(isReceiving(null, now)).toBe(false);
});
});

View file

@ -59,9 +59,3 @@ export function agoLabel(thenMs: number, nowMs: number): string {
if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`;
return `${Math.floor(seconds / 86400)}d ago`;
}
export const RECEIVING_WINDOW_MS = 5 * 60 * 1000;
export function isReceiving(lastMs: number | null, nowMs: number): boolean {
return lastMs !== null && nowMs - lastMs < RECEIVING_WINDOW_MS;
}

View file

@ -9,7 +9,6 @@ import {
} from "nuqs";
import { useCallback, useState } from "react";
import { ALL_AGENTS, type RunStatusFilter } from "./RunsToolbar";
import { RANGE_PRESETS } from "./TimeRangeControls";
import type { TimeWindow } from "./TracesTimeline";
import type { TraceSummary } from "./traceTypes";
@ -20,7 +19,6 @@ export type TraceView = (typeof TRACE_VIEWS)[number];
export const SPAN_TABS = ["content", "request", "attributes"] as const;
export type SpanTab = (typeof SPAN_TABS)[number];
const STATUS_FILTERS = ["all", "ok", "error"] as const satisfies readonly RunStatusFilter[];
const RANGE_HOURS = RANGE_PRESETS.map((preset) => preset.hours);
type RangeHours = (typeof RANGE_PRESETS)[number]["hours"];
const isRangeHours = (hours: number): hours is RangeHours => RANGE_HOURS.includes(hours as RangeHours);
@ -61,8 +59,6 @@ export const OPEN_TRACE_PARSERS = {
export const RUN_FILTER_PARSERS = {
q: parseAsString.withDefault(""),
agent: parseAsString.withDefault(ALL_AGENTS),
status: parseAsStringLiteral(STATUS_FILTERS).withDefault("all"),
hours: parseAsNumberLiteral(RANGE_HOURS).withDefault(DEFAULT_RANGE_HOURS),
from: parseAsInteger,
to: parseAsInteger,
@ -143,25 +139,9 @@ export function useLocalRunSelection(initialSpanId: string | null): RunSelection
return { spanId, view, spanTab, stepQuery, errorsOnly, selectSpan, setView, setSpanTab, setStepQuery, setErrorsOnly };
}
export interface RunFilterRouting {
query: string;
agent: string;
status: RunStatusFilter;
setQuery: (query: string) => void;
setAgent: (agent: string) => void;
setStatus: (status: RunStatusFilter) => void;
}
export function useRunFilterRouting(): RunFilterRouting {
const [{ q, agent, status }, setParams] = useQueryStates(RUN_FILTER_PARSERS);
return {
query: q,
agent,
status,
setQuery: useCallback((q: string) => void setParams({ q }), [setParams]),
setAgent: useCallback((agent: string) => void setParams({ agent }), [setParams]),
setStatus: useCallback((status: RunStatusFilter) => void setParams({ status }), [setParams]),
};
export function useRunFilterRouting(): { query: string; setQuery: (query: string) => void } {
const [query, setQuery] = useQueryState("q", RUN_FILTER_PARSERS.q);
return { query, setQuery: useCallback((q: string) => void setQuery(q), [setQuery]) };
}
export function useRangeHoursRouting(): [number, (hours: number) => void] {

View file

@ -61,6 +61,16 @@ describe("createApiClient", () => {
expect(init.body).toBe(JSON.stringify({ model_name: "gpt" }));
});
it("reports a non-JSON success body as an ApiError naming the path instead of a parse error", async () => {
const html = new Response("<!DOCTYPE html><html></html>", { headers: { "Content-Type": "text/html" } });
const client = createApiClient({ getBaseUrl: () => "", fetchImpl: vi.fn(async () => html) });
const error = await client.get("/lens").catch((e: unknown) => e);
expect(error).toBeInstanceOf(ApiError);
expect((error as ApiError).message).toBe("Expected JSON from /lens but the server returned text/html");
});
it("throws ApiError with the derived message and invokes onError on a non-2xx response", async () => {
const fetchImpl = vi.fn(async () => errorResponse(403, { error: { message: "no access" } }));
const onError = vi.fn();

View file

@ -184,7 +184,13 @@ export function createApiClient(config: ApiClientConfig): ApiClient {
async function request<T = any>(method: HttpMethod, path: string, options: RequestOptions = {}): Promise<T> {
const response = await fetchChecked(method, path, options);
const text = await response.text();
return (text ? JSON.parse(text) : undefined) as T;
if (!text) return undefined as T;
try {
return JSON.parse(text) as T;
} catch {
const type = response.headers.get("content-type") ?? "an unknown content type";
throw new ApiError(`Expected JSON from ${path} but the server returned ${type}`, response.status, text);
}
}
async function getBlob(path: string, options: RequestOptions = {}): Promise<Blob> {

View file

@ -0,0 +1,36 @@
import { RuleTester } from "eslint";
import rule from "../../scripts/eslint-rules/no-arbitrary-design-value.mjs";
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: "latest",
sourceType: "module",
parserOptions: { ecmaFeatures: { jsx: true } },
},
});
const arbitrary = (code: string, token: string) => ({ code, errors: [{ messageId: "arbitrary", data: { token } }] });
ruleTester.run("no-arbitrary-design-value", rule as never, {
valid: [
'const c = "text-xs leading-tight tracking-wider rounded-sm border";',
'const c = "grid-cols-[minmax(0,1fr)_auto] w-[180px] left-[calc(50%-0.5px)]";',
'const c = "data-[side=right]:sm:max-w-[90vw] [&>p]:mb-0";',
'const c = "ease-[cubic-bezier(0.4,0,0.2,1)]";',
'const el = <div className="text-sm text-muted-foreground" />;',
],
invalid: [
arbitrary('const c = "flex text-[13px]";', "text-[13px]"),
arbitrary('const c = "text-[#e5484d]";', "text-[#e5484d]"),
arbitrary('const c = "tracking-[-0.26px]";', "tracking-[-0.26px]"),
arbitrary('const c = "leading-[1.2]";', "leading-[1.2]"),
arbitrary('const c = "rounded-[4px]";', "rounded-[4px]"),
arbitrary('const c = "rounded-t-[4px]";', "rounded-t-[4px]"),
arbitrary('const c = "border-[0.67px]";', "border-[0.67px]"),
arbitrary('const c = "[border-left-width:1px]";', "[border-left-width:1px]"),
arbitrary('const c = "dark:text-[#8b9bff]";', "dark:text-[#8b9bff]"),
arbitrary('const c = "data-[side=top]:text-[11px]";', "data-[side=top]:text-[11px]"),
arbitrary("const c = `px-2 ${x} text-[10px]`;", "text-[10px]"),
arbitrary('const el = <div className="text-[12px]" />;', "text-[12px]"),
],
});

View file

@ -227,3 +227,33 @@ if (typeof window !== "undefined") {
disconnect() {}
};
}
// jsdom lacks the layout and input APIs ProseMirror editors use. Browsers report the selection as an edit's
// target range; layout calls only need a value of the right shape, since tests do not assert on positions.
if (typeof InputEvent !== "undefined" && !("getTargetRanges" in InputEvent.prototype)) {
Object.defineProperty(InputEvent.prototype, "getTargetRanges", {
configurable: true,
value: () => {
const selection = document.getSelection();
return selection && selection.rangeCount > 0 ? [selection.getRangeAt(0)] : [];
},
});
}
if (typeof Range !== "undefined" && !("getClientRects" in Range.prototype)) {
Object.defineProperties(Range.prototype, {
getClientRects: { configurable: true, value: () => [] },
getBoundingClientRect: { configurable: true, value: () => new DOMRect() },
});
}
// The virtualized runs list renders no rows in a zero-height viewport, and jsdom reports 0 for every offsetHeight.
if (typeof HTMLElement !== "undefined") {
Object.defineProperty(HTMLElement.prototype, "offsetHeight", {
configurable: true,
get(this: HTMLElement) {
return this.dataset.testid === "runs-table" ? 720 : 0;
},
});
}
if (typeof document !== "undefined" && !("elementFromPoint" in document)) {
Object.defineProperty(document, "elementFromPoint", { configurable: true, value: () => null });
}