feat(ui): share trace drawer as a closable SidePanel and polish Lens (#44473)
Some checks failed
CI Coverage / assert-ci-coverage (push) Waiting to run
CodeQL / Analyze (actions) (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
CodSpeed Benchmarks / benchmarks (push) Waiting to run
Helm unit test / unit-test (push) Waiting to run
Lens Worker Image / lens-worker-image (push) Waiting to run
Publish basedpyright base counts / publish (push) Waiting to run
Scorecard supply-chain security / Scorecard analysis (push) Waiting to run
Code Quality Checks / code-quality (push) Waiting to run
Code Quality Checks / python-310-import-smoke (push) Waiting to run
UI Unit Tests / ui-unit-tests (push) Waiting to run
Postgres Tests / roi-database (push) Waiting to run
Postgres Tests / proxy-security (push) Waiting to run
Postgres Tests / schema-migration (push) Waiting to run
Postgres Tests / proxy-behavior (push) Waiting to run
LiteLLM Rust / rust-lint (push) Waiting to run
LiteLLM Rust / rust-test (push) Waiting to run
LiteLLM Rust / rust-wheel (push) Waiting to run
Unit Tests: Documentation Validation / documentation (push) Waiting to run
Unit Tests: Proxy DB Operations / assert-shard-coverage (push) Waiting to run
Unit Tests: Proxy DB Operations / auth-checks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / budgets (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / custom-logging (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / db-and-spend (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / endpoints-and-responses (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / guardrails-hooks (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / jwt-and-keys (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / key-generation (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / logging-misc (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-runtime (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-server-core (push) Blocked by required conditions
Unit Tests: Proxy DB Operations / proxy-utils (push) Blocked by required conditions
Unit Tests / All Other Providers (push) Waiting to run
Unit Tests / Vertex AI (push) Waiting to run
Unit Tests / mcp-integration (push) Waiting to run
Unit Tests / misc (push) Waiting to run
Unit Tests / proxy-auth (push) Waiting to run
Unit Tests / proxy-endpoints (push) Waiting to run
Unit Tests / proxy-extras (push) Waiting to run
Unit Tests / responses-caching-types (push) Waiting to run
GitHub Actions Security Analysis / zizmor (push) Waiting to run
Unit Tests / caching-local (push) Waiting to run
Unit Tests / core-utils (push) Waiting to run
Unit Tests / enterprise-package (push) Waiting to run
Unit Tests / enterprise-routing (push) Waiting to run
Unit Tests / integrations (push) Waiting to run
Unit Tests / proxy-infra (push) Waiting to run
Unit Tests / proxy-infra-root (push) Waiting to run
Unit Tests / proxy-server (push) Waiting to run
Terraform Provider / gofmt, vet, build, test (push) Has been cancelled
Terraform Provider / Provider endpoints vs proxy OpenAPI schema (push) Has been cancelled

* feat(ui): extract trace drawer into a shared SidePanel that closes on outside press

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): stronger Lens frame border and header spacing

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): align Lens notch fillet with the notch border

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): centered Lens loading/error states and proxy JSON calls in dev

The dev server answered GET /lens with the Lens page HTML because the UI route shadowed the proxy fallback rewrite. JSON API requests now go to the proxy before page routes, and a non-JSON success body raises a readable ApiError.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui): theme-scale type and shape in trace views, aligned pane bars

Add local/no-arbitrary-design-value, scoped to TraceView and Lens, banning
arbitrary font size, tracking, leading, radius, border and CSS property
values. Map the Figma-export values onto the theme scale and replace hex
colors with info/destructive tokens.

Add PaneBar, a fixed-height bordered row, and build the step tree and span
detail headers from it so their borders line up across the split.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): restore AgentTracesSection emptied in e7c3092571

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ui): move Set up tracing into the empty runs state

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui): let the runs table gate the setup CTA on an empty range

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ui): mark Lens demo mode with a blue toggle and frame instead of a banner

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): draw Lens notch corners with CSS borders and thicken the demo frame

The SVG corner strokes did not snap to the same device pixels as the tab and
card borders, leaving a visible offset at the join.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui): drop the redundant Lens timeline header

The status, run counts, truncated agent legend and range span all repeated the Live toggle, runs table and range picker

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep drawer shortcuts out of open menus and use usehooks-ts for timers and observers

SidePanel J/K/Esc now yields to menus and listboxes, not just dialogs.
The step tree shortcut footer wraps instead of clipping in narrow columns.
Replace hand-rolled timeout, keydown, media query and ResizeObserver effects
with usehooks-ts, and drop routine doc comments.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): offer tracing setup when filters hide every run

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ui): replace Lens runs filters with a single ProseMirror query box

Agent and status dropdowns are gone. One query box (react-prosemirror) takes
free text plus key:value clauses (-key:value, key:*glob*) over name, agent,
status, model, input and trace_id, with field and value autocomplete. The
editor emits after a 150ms pause so typing no longer re-renders the runs view
per keystroke, and the URL keeps only q.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): keep the open trace on screen while the next one loads

Switching to an unvisited trace remounted the panel and flashed a loading skeleton.
The drawer now keeps the previous trace visible, dimmed and inert, until the new one arrives.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(ui): virtualize the Lens runs table and drop its status footer

The footer's run count only tracked how many pages had loaded and
"Updated just now" never changed, so it carried no signal. The zoom
clear button moves onto the timeline. Rows now render through
@tanstack/react-virtual so scrolling deep into a range stays cheap

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refactor(ui): load traces with Suspense and show the previous run via useDeferredValue

Replaces keepPreviousData with the React pattern for showing stale content while fresh content loads.
Load failures go through an error boundary that retries the query on reset.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(ui): open investigation details from list rows instead of the edit dialog

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Yujong Lee <yujong@berri.ai>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
devin-ai-integration[bot] 2026-10-04 02:20:14 +00:00 • committed by GitHub
parent c80e6e2474
commit 5ddcc45b3a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
58 changed files with 2509 additions and 1027 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());
@ -179,9 +177,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 () => {
@ -205,4 +201,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";
vi.mock("../../networking", () => ({
@ -115,7 +116,8 @@ describe("AgentTracesSection", () => {
.mockResolvedValueOnce({ data: 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();
@ -168,7 +170,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 () => {
@ -203,10 +204,12 @@ describe("AgentTracesSection", () => {
vi.mocked(agentTraceListCall).mockResolvedValue({ ...(traceList as TracePage), data: [] });
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 () => {
@ -265,7 +268,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();
@ -277,13 +280,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);
});
@ -306,7 +309,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();
});
@ -323,22 +325,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),
data: [
@ -351,20 +355,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 () => {
@ -492,11 +495,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"
@ -506,18 +509,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 () => {
@ -600,13 +603,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");
@ -635,7 +638,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();
});
@ -645,13 +648,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).data 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}
@ -233,30 +200,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 (
@ -265,39 +215,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).data[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";
@ -24,6 +26,8 @@ interface AgentTracesTableProps {
onLoadMore: () => void;
onOpenTrace: (trace: TraceSummary) => void;
selectedKey?: string | null;
rangeEmpty?: boolean;
onSetUpTracing: () => void;
}
export const formatCost = (cost: number): string => {
@ -33,10 +37,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";
@ -70,6 +77,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,
@ -81,6 +188,8 @@ export function AgentTracesTable({
onLoadMore,
onOpenTrace,
selectedKey = null,
rangeEmpty = false,
onSetUpTracing,
}: AgentTracesTableProps) {
const settled = !isLoading && !error;
const isEmpty = settled && !hasMore && traces.length === 0;
@ -91,6 +200,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
@ -99,7 +209,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" />
@ -119,70 +229,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>
@ -194,16 +252,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

@ -133,6 +133,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);
@ -271,6 +301,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)!;

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";
@ -18,7 +20,7 @@ import { SpanIcon } from "./SpanIcon";
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 {
@ -80,16 +82,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" />}
@ -192,9 +190,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));
@ -249,14 +248,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) {
@ -278,10 +277,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 (
@ -364,15 +363,86 @@ 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));
}
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,
@ -382,14 +452,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(() => {
@ -397,41 +466,6 @@ export function RunView({ traceId, traceRef, selection, accessToken, onBack, emb
}, [canSeek, isFetching, isError, fetchNextPage]);
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}
@ -439,7 +473,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
@ -476,6 +513,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 });
}